← All posts

Real-Time Log Level Control Without Touching Your Code

The typical debugging cycle for production log issues involves SSH, a grep through recent logs, and either resigning yourself to INFO-level noise or deploying a code change to enable DEBUG. Neither is good. Neither scales.

Smpl Logging is our approach to remote log level control: a system where you can change any logger’s level in any running service from a web console, and the change takes effect within seconds without a deployment, a restart, or any code change beyond the initial one-line integration.

This post covers the design decisions that make this work: auto-discovery, the resolution hierarchy, the WebSocket-based live update pipeline, and the adapter interface that makes the system framework-agnostic.

The Core Insight: Monkey-Patching Is the Right Tool Here

Adding Smpl Logging to an existing application is one line:

SmplClient().logging.install()

That’s it. No manually registering each logger. No restructuring your logging configuration. After this call, the SDK discovers every logger your application already uses, reports them to the logging service, and begins listening for level-change commands.

The discovery mechanism works by monkey-patching the logging framework’s logger creation function. When your code calls logging.getLogger("myapp.database"), the logging framework normally creates a new logger object. With the SDK installed, that call is intercepted. The SDK records that a logger with that name was accessed and reports it.

Monkey-patching has a bad reputation — it’s often a code smell. In this case, it’s the right tool because the alternative (requiring developers to explicitly register loggers) creates integration friction that defeats the purpose. You can’t discover loggers you don’t know about. The only way to find them all is to intercept where they’re created.

The Resolution Hierarchy

Logging frameworks organize loggers in a hierarchy by name. myapp.database.queries is a child of myapp.database, which is a child of myapp. Level changes propagate down: if you set myapp to DEBUG, all children inherit DEBUG unless they have an explicit override.

Smpl Logging adds its own resolution chain on top of the framework’s native hierarchy:

  1. Environment variable override. SMPLKIT_LOG_LEVEL_MYAPP_DATABASE=DEBUG sets a level that can’t be changed remotely. Useful for situations where you want a specific logger fixed during a debugging session.

  2. Remote base level. The level set through the Smpl Logging console.

  3. Group level. Loggers can be assigned to groups (“database”, “auth”, “api”). A group-level change affects all loggers in the group simultaneously.

  4. Dot-notation ancestry. The native logging hierarchy — if a logger’s immediate parent has a remote level set, the child inherits it.

  5. System default. INFO if nothing else applies.

The resolution runs top to bottom; the first applicable setting wins. This means the console can always override the application default, but the environment variable can pin a level that the console can’t change — useful for emergencies where you need to guarantee a logger stays at a specific level regardless of console state.

The Managed / Unmanaged Classification

Not every discovered logger needs to be actively managed. An application with a hundred libraries all logging at various levels can generate a lot of console noise if every logger is presented with equal prominence.

We classify loggers as managed (explicitly added to the console and configured with a remote level) or discovered (seen but not yet configured). The console’s default view shows managed loggers. Discovered loggers are visible in a separate panel.

The managed/discovered distinction matters for behavior:

  • A managed logger’s level is controlled by the remote configuration, flowing down the resolution hierarchy.
  • A discovered logger’s level is whatever the application’s native logging configuration sets it to. The SDK watches it and reports its current level, but doesn’t change it.

This means you can integrate Smpl Logging and immediately see what loggers exist in your application, then selectively add them to managed control as needed. You’re not committed to remote-controlling every logger the moment you install the SDK.

The Adapter Interface

Different logging frameworks have different APIs. Python’s stdlib logging, Loguru, Node’s Winston, Go’s slog — each has its own model for what a “logger” is, how levels are represented, and how to hook into logger creation.

The SDK defines a pluggable LoggingAdapter interface with five methods:

  • discover() — return all loggers currently in the framework
  • apply_level(logger_name, level) — set a specific logger to a specific level
  • install_hook() — start intercepting logger creation to discover new loggers
  • uninstall_hook() — remove the interception
  • get_level(logger_name) — return the current level of a logger

For each supported language and framework, we ship a concrete adapter. Python ships two: stdlib (covering Python’s built-in logging and anything that inherits from it, like Django’s) and loguru. TypeScript ships winston and pino. Go ships slog and zap. Ruby ships stdlib-logger and semantic-logger.

The adapter interface is public. If your team uses a custom logging framework — an internal wrapper, a DSL built on top of an existing library — you can implement the adapter interface and call client.logging.register_adapter(MyAdapter()). The SDK treats your custom adapter identically to the built-ins.

This design puts us in the business of writing five adapter implementations per language (one per popular framework) rather than one custom-everything per language. It’s more work upfront but dramatically more maintainable.

The WebSocket Update Pipeline

Level changes flow from the console to connected SDK clients via WebSocket. When you click the level dropdown for myapp.database in the console and change it from INFO to DEBUG, here’s what happens:

  1. The console calls the logging service’s REST API: PATCH /api/v1/loggers/myapp.database with {"level": "DEBUG"}.
  2. The service updates the logger record in the database and emits a change event.
  3. The change event is broadcast to all SDK clients connected to that account and environment.
  4. Each SDK client receives the event and calls adapter.apply_level("myapp.database", "DEBUG").
  5. The logger’s level changes in the running process. Within milliseconds of the console click.

The WebSocket connection is maintained by the SDK in a background thread (Python, Java, C#, Ruby) or async task (TypeScript, Go). It’s transparent to the application — no application code manages the connection lifecycle.

If the WebSocket connection drops, the SDK reconnects automatically with exponential backoff. During the reconnection window, loggers continue operating at their last-known levels. There’s no degraded behavior — level changes just don’t propagate until the connection is restored.

Level Quantization

Logging frameworks don’t all agree on what log levels exist. Python’s stdlib has DEBUG, INFO, WARNING, ERROR, CRITICAL. Loguru adds TRACE and SUCCESS. SLF4J has TRACE, DEBUG, INFO, WARN, ERROR. Go’s slog has Debug, Info, Warn, Error.

Smpl Logging defines a canonical seven-level scale: TRACE, DEBUG, INFO, WARN, ERROR, FATAL, OFF. Adapters are responsible for mapping between the canonical scale and the framework’s native levels.

Where a framework doesn’t have a level (no TRACE in stdlib Python, no FATAL in slog), the adapter maps as closely as possible and documents the approximation. The Python stdlib adapter maps TRACE to DEBUG when setting a level (the closest available level) and maps DEBUG back to DEBUG when reporting (there’s no way to distinguish “this is a TRACE logger mapped to DEBUG” from “this is a genuine DEBUG logger”).

Customers who need TRACE-precision should use a framework that natively supports it (Loguru, semantic_logger) and the corresponding adapter.

What We’d Revisit

Dependency-aware level propagation. Today, setting a parent logger to DEBUG sets all children to DEBUG. There’s no way to set a parent without affecting children. Some operators want to set a group default and override specific noisy children independently — the current resolution model doesn’t support this cleanly.

Level scheduling. “Turn on DEBUG for the next 15 minutes, then restore INFO automatically.” This is a common use case for production debugging sessions where you want to make sure you don’t forget to turn DEBUG back off. The infrastructure for it is straightforward; we just haven’t built the UI.

Level change attribution. When a level changes, the SDK knows a change occurred but doesn’t currently store who made the change. This lands in the audit history work, but it’s specifically useful for logging because “who turned on DEBUG in production” is a frequently-asked question after the fact.

Smpl Logging discovers loggers automatically via a monkey-patching hook, resolves levels through a five-stage hierarchy, and propagates changes to connected SDK clients over WebSocket within seconds of a console change.