Skip to content

symfonic.services.observability.trace

trace

Trace propagation, in the standard library.

The shipped trace context lived inside :mod:symfonic.observability.otel, which is gated behind the [otel] extra. That made "is this run correlated with that log line?" a question only answerable when an optional dependency was installed — so adopters without the extra had run ids and nothing to join them to.

Here the trace scope is a W3C traceparent (a hex trace id, a hex span id, a sampled flag) carried in a :mod:contextvars variable. It works with zero OTEL installed, and the OTEL adapter adopts this scope rather than minting a second, disagreeing one.

TraceScope dataclass

TraceScope(trace_id: str, span_id: str, sampled: bool = True)

A W3C trace context, without a W3C library.

traceparent

traceparent() -> str

Render the traceparent header value.

Source code in src/symfonic/services/observability/trace.py
def traceparent(self) -> str:
    """Render the ``traceparent`` header value."""
    return f"{_VERSION}-{self.trace_id}-{self.span_id}-{'01' if self.sampled else '00'}"

bind_run

bind_run(scope: RunScope, trace: TraceScope) -> tuple[object, object]

Bind both carriers; returns the tokens :func:unbind_run needs.

Source code in src/symfonic/services/observability/trace.py
def bind_run(scope: RunScope, trace: TraceScope) -> tuple[object, object]:
    """Bind both carriers; returns the tokens :func:`unbind_run` needs."""
    return _run_scope.set(scope), _trace_scope.set(trace)

current_run_scope

current_run_scope() -> RunScope | None

The run being observed in this context, or None outside a run.

Source code in src/symfonic/services/observability/trace.py
def current_run_scope() -> RunScope | None:
    """The run being observed in this context, or ``None`` outside a run."""
    return _run_scope.get()

current_trace

current_trace() -> TraceScope | None

The trace scope of the run being observed, or None.

Source code in src/symfonic/services/observability/trace.py
def current_trace() -> TraceScope | None:
    """The trace scope of the run being observed, or ``None``."""
    return _trace_scope.get()

parse_traceparent

parse_traceparent(header: str) -> TraceScope | None

Parse a traceparent header, or return None.

Refusing is deliberate. A malformed header that is "best-effort repaired" produces a trace id that correlates with nothing, which is worse than no correlation at all because it looks like one.

Source code in src/symfonic/services/observability/trace.py
def parse_traceparent(header: str) -> TraceScope | None:
    """Parse a ``traceparent`` header, or return ``None``.

    Refusing is deliberate. A malformed header that is "best-effort repaired"
    produces a trace id that correlates with nothing, which is worse than no
    correlation at all because it looks like one.
    """
    if not header:
        return None
    parts = header.split("-")
    if len(parts) != 4:
        return None
    version, trace_id, span_id, flags = parts
    if version != _VERSION:
        return None
    if len(trace_id) != 32 or len(span_id) != 16 or len(flags) != 2:
        return None
    if trace_id == _ZERO_TRACE or span_id == _ZERO_SPAN:
        return None
    try:
        int(trace_id, 16), int(span_id, 16), int(flags, 16)
    except ValueError:
        return None
    return TraceScope(
        trace_id=trace_id, span_id=span_id, sampled=bool(int(flags, 16) & 0x01)
    )

trace_for_run

trace_for_run(run_id: str) -> TraceScope

Derive a trace scope for run_id.

The trace id is derived from the run id rather than drawn at random so the same run always lands in the same trace: a log line that only recorded a run id can still be joined to its trace after the fact, which is exactly the case where an operator needs the join and no longer has the process.

The span id is random, because two observers of the same run are two spans, not one.

Source code in src/symfonic/services/observability/trace.py
def trace_for_run(run_id: str) -> TraceScope:
    """Derive a trace scope for ``run_id``.

    The trace id is derived from the run id rather than drawn at random so the
    same run always lands in the same trace: a log line that only recorded a
    run id can still be joined to its trace after the fact, which is exactly
    the case where an operator needs the join and no longer has the process.

    The span id is random, because two observers of the same run are two spans,
    not one.
    """
    digest = hashlib.sha256(run_id.encode("utf-8", errors="ignore")).hexdigest()[:32]
    if digest == _ZERO_TRACE:  # pragma: no cover - unreachable for sha256
        digest = f"{_ZERO_TRACE[:-1]}1"
    return TraceScope(trace_id=digest, span_id=secrets.token_hex(8))

unbind_run

unbind_run(tokens: tuple[object, object]) -> None

Release the carriers bound by :func:bind_run.

A token minted in another context cannot be reset, and that happens for real: a terminal event delivered from a drain worker runs in a different task than the one that opened the run. Falling back to an explicit None keeps the leak bounded to that context instead of raising inside teardown.

Source code in src/symfonic/services/observability/trace.py
def unbind_run(tokens: tuple[object, object]) -> None:
    """Release the carriers bound by :func:`bind_run`.

    A token minted in another context cannot be reset, and that happens for
    real: a terminal event delivered from a drain worker runs in a different
    task than the one that opened the run. Falling back to an explicit ``None``
    keeps the leak bounded to that context instead of raising inside teardown.
    """
    for var, token in zip((_run_scope, _trace_scope), tokens, strict=True):
        try:
            var.reset(token)  # type: ignore[arg-type]
        except ValueError:
            var.set(None)  # type: ignore[arg-type]