This document explains the architectural decisions behind Nullscope.
Nullscope's core design principle is that telemetry should have zero runtime cost when disabled. This is achieved through a singleton no-op pattern.
When NULLSCOPE_ENABLED is not set (or not "1"), calling TelemetryContext() returns a pre-allocated singleton instance of _NoOpTelemetryContext:
_NO_OP_SINGLETON = _NoOpTelemetryContext()
def TelemetryContext(*reporters):
if _NULLSCOPE_ENABLED:
return _EnabledTelemetryContext(*reporters)
return _NO_OP_SINGLETON # Always the same instanceThe no-op context is:
- Immutable (
@dataclass(frozen=True, slots=True)) - Stateless (no instance variables)
- Self-returning (
__call__returnsself, making it its own context manager)
This means disabled telemetry reduces to:
- One dict lookup for
_NULLSCOPE_ENABLED(evaluated once at import) - Return of a pre-existing object reference
- No allocations, no context manager overhead, no timing calls
Nullscope evaluates environment variables once at module import:
_NULLSCOPE_ENABLED = os.getenv("NULLSCOPE_ENABLED") == "1"This deliberate design choice means:
- No runtime checks: Every
with telemetry("scope")doesn't need to check env vars - Branch prediction friendly: The enabled/disabled path is fixed for the process lifetime
- Predictable behavior: Telemetry state can't change mid-request
The tradeoff is that you can't dynamically enable/disable telemetry. This is intentional—if you need dynamic control, use the reporter layer instead. In tests, reload nullscope after changing env vars.
Nullscope uses Python's contextvars module to track scope hierarchy:
_scope_stack_var: ContextVar[tuple[str, ...]] = ContextVar("scope_stack", default=())
_call_count_var: ContextVar[int] = ContextVar("call_count", default=0)This provides automatic isolation for:
- Async tasks: Each
asyncio.Taskgets its own scope stack - Threads: Each thread has independent context
- Nested scopes: Child scopes correctly report parent relationships
When you write:
async def handle_request():
with telemetry("request"):
await process_data() # Other tasks have their own scope stack
with telemetry("validation"):
... # Correctly nested under "request"The scope hierarchy "request.validation" is maintained correctly even with concurrent async operations.
Reporters implement a simple duck-typed protocol:
class TelemetryReporter(Protocol):
def record_timing(self, scope: str, duration: float, **metadata: Any) -> None: ...
def record_metric(self, scope: str, value: Any, **metadata: Any) -> None: ...-
Two methods, not many: Rather than separate methods for counters, gauges, histograms, etc., we have
record_timing(for scopes) andrecord_metric(for everything else). Themetric_typemetadata key distinguishes counter vs gauge. -
Keyword-only metadata: All contextual information flows through
**metadata. This makes the protocol stable—new metadata keys don't require protocol changes. -
Error isolation: Reporter failures are logged but don't propagate. Your application continues running even if telemetry export fails.
-
Multiple reporters:
TelemetryContext()accepts multiple reporters. All receive the same data.
Reporters can optionally implement:
flush()shutdown()
The telemetry context forwards these calls when present and logs failures without crashing application code.
Scopes form a hierarchy through dot-separated names:
with telemetry("http"):
with telemetry("parse"):
... # scope = "http.parse"The implementation:
- Maintains a stack of scope names in context vars
- On scope entry: pushes name, starts timing
- On scope exit: pops name, calculates duration, reports with full path
Nullscope automatically includes metadata with every timing:
depth: Nesting level (0 = root)parent_scope: Dot-joined parent path (orNoneat root)call_count: Incrementing counter for orderingstart_monotonic_s/end_monotonic_s: Monotonic timestampsstart_wall_time_s/end_wall_time_s: Wall clock timestamps
These keys are exported as constants from nullscope for reporter implementations:
from nullscope import DEPTH, PARENT_SCOPE, CALL_COUNT, START_WALL_TIME_SThis allows reporters to reference keys without hardcoding strings, and provides a stable contract for what metadata is always present.
Nullscope exposes telemetry.timed("scope.name") to instrument functions without manual with blocks.
- Sync functions are wrapped with a timing scope
- Async functions are also wrapped and awaited inside the scope
- In disabled mode, the decorator returns the original function unchanged
This keeps instrumentation concise while preserving the no-op-first design.