Skip to main content

Scoped overrides

A scoped override temporarily replaces (or extends) a logger's sinks for a limited execution scope. It exists for three primary use cases: capturing logs in tests, writing per-run audit trails, and enabling debug capture for a single endpoint without touching the global configuration.

Three operations

The spec (§6.1) defines three operations on an existing logger:

OperationEffect
with_sinks([...])Returns a child logger whose sinks replace the parent's.
append_sinks([...])Returns a child logger whose sinks are the parent's plus the supplied extras.
without_sinks()Returns a child logger with no sinks — emits are discarded.

Each operation returns a child logger; the parent's configuration is untouched. The returned logger is non-cached — calling Logger.get(name) from elsewhere keeps returning the original instance with its global sinks.

from dagstack.logger import Logger, InMemorySink, FileSink

logger = Logger.get("order_service")

# Replace sinks — only InMemorySink receives emits.
test_logger = logger.with_sinks([InMemorySink(capacity=100)])
test_logger.info("captured here")

# Append a sink — both the parent's and the extra receive emits.
audit_logger = logger.append_sinks([FileSink("/var/log/audit.jsonl")])
audit_logger.info("audit event")

# Discard — emits go to /dev/null.
silent_logger = logger.without_sinks()
silent_logger.info("never seen")

Lexically bounded scope

For a block-scoped capture (test case, request handler, defer pattern), the spec defines scope_sinks — a context manager that swaps sinks on the original logger for the duration of the block, then restores the previous sinks on exit.

The key difference from with_sinks: scope_sinks modifies self, so any code reaching the same logger via Logger.get(name) during the scope writes to the swapped sinks. Useful when business logic resolves loggers by name internally and you cannot pass an alternate logger down.

from dagstack.logger import Logger, InMemorySink

logger = Logger.get("order_service")
sink = InMemorySink(capacity=100)

with logger.scope_sinks([sink]):
run_business_logic() # emits via Logger.get("order_service") land in sink
# other modules calling Logger.get("order_service") inside this block
# also emit into sink

# Outside the block, emits go to the global sinks again.
assert len(sink.records()) > 0

Use cases

Tests — InMemorySink for assertions

The most common scoped-override pattern: capture records during a test, then assert against them. See the Testing guide for a full walkthrough.

Per-run audit

A workflow orchestrator can create a scoped logger per run that writes both to the global OTLP exporter (for cross-run aggregation) and to a dedicated file sink (for per-run audit). On run finalize, the file is closed and archived alongside the run's metadata.

Per-hook redaction

A plugin hook handles a sensitive payload — say, a webhook from a payment provider. A scoped logger wraps the hook with an augmented RedactionProcessor (Phase 2) that masks additional fields specific to the provider's schema, without affecting the rest of the application.

Debug session

capture_bodies: true can be enabled for a single request via a debug header plus an ACL check. The middleware constructs a scoped logger with the debug flag for the duration of that request; other concurrent requests stay in privacy mode.

Anti-patterns

  • Long-lived scoped loggers. A scoped override outliving its operation boundary is a sign you actually want a dedicated named logger (Logger.get(name)) with its own configuration. Scope = ephemeral.
  • Scope leaks across async boundaries. A binding guarantees scope isolation only inside the lexical block. If you spawn an async task inside a scope_sinks block and let it complete after the block exits, its emits land in the wrong sinks (or crash on a closed sink). Use await / defer / context-manager-aware scheduling.
  • Mutating sinks inside a scope. The scope captures references to sink objects, not deep copies. Calling sink.close() on a captured sink invalidates it for any later code that holds the same reference.

See also