← All posts

Real-time log level control without touching your code

The standard cycle for a production log mystery: SSH in, grep what’s there, and choose between squinting at INFO-level noise and shipping a deploy just to turn on DEBUG. Neither is good, and the second one means your debugging tool has a release process.

Smpl Logging is our answer: change any logger’s level in any running service from a web console, and the change lands in seconds — no deployment, no restart, no code change beyond the initial one line.

Monkey-patching is the right tool here

The entire integration:

SmplClient().logging.install()

No registering loggers, no restructuring your logging config. After that call, the SDK discovers every logger the application already uses, reports them, and starts listening for level changes.

Discovery works by monkey-patching the logging framework’s logger-creation function. When your code calls logging.getLogger("myapp.database"), the SDK intercepts the call, records that the logger exists, and gets out of the way.

Monkey-patching has a deserved reputation as a code smell. Here it’s the right tool, for a boring reason: the alternative is asking developers to register every logger by hand, and you can’t register loggers you don’t know about — the ones deep inside your dependencies are exactly the ones you’ll want at DEBUG someday. The only place to find them all is the spot where they’re created.

The resolution hierarchy

Log frameworks already organize loggers by dotted name — myapp.database.queries under myapp.database under myapp. Smpl Logging adds its own resolution chain on top. First applicable setting wins:

  1. Environment variable override. SMPLKIT_LOG_LEVEL_MYAPP_DATABASE=DEBUG — a level that can’t be changed remotely.
  2. Remote base level. Whatever the console set for the logger.
  3. Group level. Loggers can belong to groups (“database”, “auth”); one change hits the whole group.
  4. Dot-notation ancestry. The immediate parent’s remote level, if one is set.
  5. System default. INFO, if nothing else claims the logger.

The ordering encodes a policy: the console can always override the application’s defaults, but the environment variable can pin a level the console can’t touch. So if a debugging session ends with someone asking “wait, why is this logger stuck at DEBUG?”, the answer is an env var someone set on purpose, in an emergency, and the console was correctly powerless against it.

Managed vs. discovered

An application with a hundred dependencies has a lot of loggers, and presenting all of them with equal prominence makes a console nobody can use. So loggers are either managed (explicitly added to the console, level controlled remotely through the hierarchy above) or merely discovered: seen and reported, but left at whatever level the application’s native configuration gave them.

Install the SDK and you immediately see everything that exists; you then promote loggers to managed control as you actually need them. Integration doesn’t commit you to remote-controlling your entire dependency tree on day one.

The adapter interface

Every logging framework has its own idea of what a logger is. Rather than one custom integration per language, the SDK defines a five-method LoggingAdapter interface — discover(), apply_level(), install_hook(), uninstall_hook(), get_level() — and ships concrete adapters per framework: stdlib and loguru for Python, winston and pino for TypeScript, slog and zap for Go, stdlib-logger and semantic-logger for Ruby.

The interface is public. A team with an in-house logging wrapper implements the five methods, calls client.logging.register_adapter(MyAdapter()), and the SDK treats it exactly like a built-in. This keeps us in the business of writing a handful of adapters per language instead of one custom-everything per language.

The WebSocket pipeline

When you flip myapp.database from INFO to DEBUG in the console, the console PATCHes the logging service, which updates the record and emits a change event. The event broadcasts to every SDK client connected for that account and environment, each client calls adapter.apply_level("myapp.database", "DEBUG"), and the running process’s logger changes — milliseconds after the click.

The SDK holds the WebSocket connection on a background thread (or async task, depending on the language), invisible to application code. If the connection drops, the SDK reconnects with exponential backoff, and in the meantime loggers keep running at their last-known levels. Nothing degrades; changes just wait for the connection.

Level quantization

Logging frameworks don’t agree on what levels exist. Python’s stdlib runs DEBUG through CRITICAL; Loguru adds TRACE and SUCCESS; SLF4J has its five; Go’s slog has four. Smpl Logging defines a canonical seven — TRACE, DEBUG, INFO, WARN, ERROR, FATAL, OFF — and each adapter maps between the canonical scale and whatever its framework believes in.

Where a framework lacks a level, the adapter approximates and documents it. Python’s stdlib has no TRACE, so the adapter maps TRACE to DEBUG on the way in — and on the way out it reports DEBUG, because once mapped there’s no way to know the logger was ever meant to be TRACE. If you need TRACE to actually mean TRACE, use a framework that has one (Loguru, semantic_logger) and its adapter.

Still on the list

Three known gaps. Parent levels currently propagate to all children, with no way to set a group default while keeping one noisy child at WARN — operators have asked. Level scheduling — “DEBUG for 15 minutes, then back” — is the feature that would end the ritual of forgetting to turn DEBUG off; the infrastructure is straightforward and the UI doesn’t exist yet. And attribution: the SDK knows a level changed but not who changed it. That lands with the audit-history work, and logging is where it matters most, because “who turned on DEBUG in production” is a frequently asked question, always after the fact.