Application INFO logs vanish because nobody configured the root logger — the web server only configures its own

verified · provenanceused 0× by assistantsobservability

A module calls logging.getLogger(__name__) and logs at INFO, but nothing in the service ever configures the root logger — so the ASGI server hosting it happily runs, the process happily emits *some* log lines, and every INFO message from application code is silently thrown away without a single error to point at.

The signal that gives it away

- The container/process logs are not empty — they show plenty of output (access lines, startup banner, occasional WARNING/ERROR) — but a specific module's INFO lines never appear, not even once, not even right after a deploy when you know that code path just ran. - The missing logs all come from application loggers (logging.getLogger(__name__) in your own modules), while the server's own logs (e.g. uvicorn, uvicorn.access, uvicorn.error) show up fine — that split is the tell: the server configured *itself*, not the tree your code hangs off. - Grepping your service's root directory for basicConfig/dictConfig comes back empty for the service entrypoint — nobody ever called it. If your framework's run() call (e.g. uvicorn.run(...)) is invoked *without* a log_config= argument, that confirms the server isn't going to configure anything beyond its own namespaced loggers either. - A sibling/neighboring module in the same codebase already hit this and quietly worked around it with a different logging library (e.g. loguru) configured only in its own file — that's a strong smell that the root cause was never actually fixed, just avoided locally, one file at a time. - If you do get *some* output from the affected logger, it's only at WARNING and above, and it appears on stderr with no formatting (bare message, no timestamp/level/logger-name prefix) — that bare, unformatted shape is the fingerprint of Python's fallback handler catching it, not your own formatter.

How to fix it

1. Confirm nothing configures the root logger. Read the service entrypoint (wherever the process boots — server.py, main.py, app.py) end to end for logging.basicConfig(, logging.config.dictConfig(, or a framework log_config= argument. If none exists anywhere in the boot path, the root logger has exactly one handler in it: none. 2. Understand why the web server doesn't save you. An ASGI/WSGI server like uvicorn sets up its *own* named loggers (uvicorn, uvicorn.access, uvicorn.error) with propagate=False on them by design — precisely so its access/error lines don't get duplicated if the application *also* configures the root logger later. The side effect: configuring the server configures nothing for logging.getLogger(__name__) in your own modules, which sit on a completely different branch of the logger tree and propagate straight up to root — where there's no handler waiting. 3. Know where the missing logs actually go. They don't vanish into nothing — they hit Python's logging.lastResort handler: a StreamHandler to stderr, hardcoded at WARNING severity, with no formatting. That's why INFO disappears completely while an occasional WARNING/ERROR from the same module *does* show up, bare and unformatted — people reading the logs assume "logging must be broken" when actually it's working exactly as documented, just one severity level short of what you needed. 4. Fix at the entrypoint, once, not per-module. Add an explicit logging.basicConfig(level=logging.INFO, stream=sys.stdout) (or dictConfig if you need per-logger control) at import time in the single file that boots the service — before the server starts serving requests. This is a one-line, low-risk change (small blast radius, no behavior change to request handling) that fixes every module in the codebase at once, current and future. 5. Immediately raise the level on noisy third-party loggers, in the same change, or the fix you just shipped turns into a flood: logging.getLogger("httpx").setLevel(logging.WARNING), and the same for httpcore, the cloud SDK you use, urllib3, asyncio. These libraries log plenty at INFO/DEBUG and were previously silent for the exact same reason your own code was — configuring the root logger un-silences all of them at once, not just yours. 6. Don't touch the server's own loggers. Leave uvicorn's (or equivalent) logger configuration alone — it already has its own handler and propagate=False; nothing about this fix should re-route or duplicate its output. The fix is additive at the root, not a rewrite of the server's logging setup. 7. Verify by reading log severity, not just log presence. After the fix, confirm the previously-invisible INFO lines appear *with* your formatter's shape (timestamp, level, logger name) — not bare on stderr. Bare-and-unformatted after the fix means something is still falling through to lastResort, i.e. some logger in the chain still isn't propagating to the now-configured root.

```python # server.py — before: nothing configures the root logger # uvicorn.run(app, host=..., port=...) # no log_config= → app's own INFO logs go nowhere

# server.py — after: one explicit call at import time, before serving import logging, sys

logging.basicConfig(level=logging.INFO, stream=sys.stdout) for noisy in ("httpx", "httpcore", "urllib3", "asyncio"): logging.getLogger(noisy).setLevel(logging.WARNING)

# uvicorn.run(app, host=..., port=...) # unchanged — uvicorn's own loggers untouched ```

What doesn't work

- Swapping to a different logging library file-by-file. A neighboring module in the same codebase had already hit this exact symptom and "solved" it by configuring loguru inside its own file. That makes that one file's logs visible again but leaves the systemic gap open for every other module — the next new file written by the next person hits the identical silent gap, because the actual cause (no root configuration) was never touched. - Assuming the web server's logging setup covers your application code. It is tempting to think "the server clearly has working logs, so logging is configured" — but a server configuring its own namespaced, propagate=False loggers says nothing about the root logger your application code actually propagates to. Two different subtrees, two different outcomes. - Trusting `str`/absence of errors as proof logging works. There is no exception, no startup warning, no crash — the process behaves as if everything is fine, because from the interpreter's point of view it is: lastResort is a documented, intentional fallback, not a bug. The only way to catch this is to actually go looking for the specific INFO line you expect and notice it isn't there. - Adding handlers deep inside individual modules "to be safe." Attaching a handler directly to logging.getLogger(__name__) in application code works around the symptom per-module but multiplies configuration surface (formats drift, someone forgets a module) — the one-time root fix at the entrypoint is strictly less code and covers modules that don't exist yet. - Raising the level on third-party loggers without also configuring root first. Doing step 5 alone, without step 4, silences noise that wasn't reaching anywhere anyway (root still has no handler) — the ordering matters: configure root first, then quiet the libraries, or you'll conclude the fix "didn't do anything."

Related

- Scrubbing PII from an error-tracking tool needs two separate fixes — code AND platform setting — a sibling observability lesson in the same shape: a fix that looks complete from inside your code (here, root logger configuration; there, a code-side scrub hook) can still miss a second layer that only the platform/runtime controls, and vice versa. - A blanket `except Exception` swallows an ImportError and degrades into a misleading business error — a related way a real signal gets silently downgraded: there a broad except swallows a structural error into a misleading low-severity message; here the language's own fallback handler downgrades every INFO line to invisible without any except involved at all. - Some exceptions stringify to an empty string — logging only `str(e)` hides the real error — the companion lesson once logging *is* configured: getting log lines to appear is necessary but not sufficient if what you log (str(e) alone) can itself be empty or uninformative. - Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect) — another case of a framework configuring only the layer it owns (its own resume/pause internals) while leaving an adjacent layer (application-level state) entirely on the caller to wire up correctly.

Verified against

5 claims checked against these sources

Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.