Current design
We expose three observability mechanisms in instrumentation/, and each one reaches its call sites through a different seam:
-
OpenTelemetry-style span Tracer — injected as a constructor argument on the policy:
# pipeline/policies/tracing_policy.py
def __init__(self, *, tracer: Tracer | None = None) -> None:
self._tracer = tracer or NOOP_TRACER
So the span tracer is configured when you build the pipeline (TracingPolicy(tracer=...) in pipeline/defaults.py).
-
Per-operation HttpTracer — injected through a context factory carried on the threaded object:
# pipeline/policies/redirect.py
def resolve_http_tracer(ctx: PipelineContext) -> HttpTracer:
...
tracer = ctx.call.instrumentation_context.http_tracer_factory.create()
http_tracer_factory is a field on InstrumentationContext (instrumentation/instrumentation_context.py), resolved per-operation and cached in ctx.data. Both TracingPolicy and AsyncOperationTracingPolicy reach it this way.
-
Correlation ids — a third path: module-level contextvars (instrumentation/correlation.py), written by TracingPolicy.send via bind_correlation and read by ClientLogger.
-
MetricsContext (instrumentation/metrics.py) — defines Counter / UpDownCounter / Histogram factories with no-op defaults, but it is referenced nowhere outside its own module and instrumentation/__init__.py. It is not a field on InstrumentationContext, no policy builds an instrument, and no test exercises it. Its module docstring states the no-op default exists "so SDK code can always emit metrics without conditional checks" — but no such emission path exists.
Trade-off / concern
The two tracing seams cover the same logical concern (request telemetry) yet use opposite injection styles. A consumer wiring telemetry has to learn that the span tracer is a TracingPolicy constructor argument while the HttpTracer is an InstrumentationContext field, and discover that asymmetry by reading source — there is no single place that says "here is where observability plugs in."
MetricsContext is the sharper version of the same problem. Its SPI shape — what instruments it builds, where it gets injected, whether it is per-operation like http_tracer_factory or pipeline-scoped like the span Tracer — was frozen before any caller existed to validate it. The obvious first emitters (request-duration histogram, retry-attempt counter) have no home that matches either tracing seam, which is exactly the signal that the contract was shaped against guesswork. Shipping a public contract with no in-tree consumer means the first real emitter may well force a breaking reshape. (The "metrics SPI has no core emitter" point is tracked separately as an implementation gap; the design-level concern here is the premature, unvalidated SPI shape and the split injection style across the whole observability surface.)
Proposed direction
Unify injection on the one object the call already threads end to end: InstrumentationContext, which already carries http_tracer_factory.
- Add
tracer (and, once it has a real consumer, metrics_context) fields to InstrumentationContext, each defaulting to its no-op singleton. TracingPolicy then reads ctx.call.instrumentation_context.tracer instead of taking a constructor argument, matching how HttpTracer is already resolved and collapsing two seams into one.
- For metrics, validate the shape with a real caller before freezing it: wire at least one concrete in-core emitter — e.g.
RetryPolicy recording an attempts counter, OperationTracingPolicy recording operation duration through ctx.call.instrumentation_context.metrics_context. Whichever seam the emitter needs is then the seam the SPI commits to. If we are not ready to add an in-core emitter, the alternative is to move the metrics SPI into the sibling backend package (alongside the real OTel implementation) until an in-core consumer exists, rather than ship a frozen contract with no caller in this repo.
Trade-offs of this direction:
- Adding fields widens
InstrumentationContext, a frozen dataclass, and couples the tracer/metrics lifetime to the trace context. That coupling is acceptable since both are already per-operation concerns that travel with the trace, and it is strictly more consistent than the status quo.
- Moving the span tracer off the
TracingPolicy constructor is a public-API change for anyone currently passing tracer=; it would want a deprecation path or a release note.
- Wiring a concrete metrics emitter means picking metric names/units now, but that is the point — a real caller is what turns a guessed contract into a validated one.
Open question for discussion
Do we want all observability handles to live on InstrumentationContext (one seam, per-operation lifetime), or do we deliberately want span-tracer configuration to stay at pipeline-build time? Either is defensible, but the current mix of both for the same concern is the part worth resolving — and it should be resolved before the metrics contract gains an external consumer that locks its shape in.
Current design
We expose three observability mechanisms in
instrumentation/, and each one reaches its call sites through a different seam:OpenTelemetry-style span
Tracer— injected as a constructor argument on the policy:So the span tracer is configured when you build the pipeline (
TracingPolicy(tracer=...)inpipeline/defaults.py).Per-operation
HttpTracer— injected through a context factory carried on the threaded object:http_tracer_factoryis a field onInstrumentationContext(instrumentation/instrumentation_context.py), resolved per-operation and cached inctx.data. BothTracingPolicyandAsyncOperationTracingPolicyreach it this way.Correlation ids — a third path: module-level
contextvars(instrumentation/correlation.py), written byTracingPolicy.sendviabind_correlationand read byClientLogger.MetricsContext(instrumentation/metrics.py) — definesCounter/UpDownCounter/Histogramfactories with no-op defaults, but it is referenced nowhere outside its own module andinstrumentation/__init__.py. It is not a field onInstrumentationContext, no policy builds an instrument, and no test exercises it. Its module docstring states the no-op default exists "so SDK code can always emit metrics without conditional checks" — but no such emission path exists.Trade-off / concern
The two tracing seams cover the same logical concern (request telemetry) yet use opposite injection styles. A consumer wiring telemetry has to learn that the span tracer is a
TracingPolicyconstructor argument while theHttpTraceris anInstrumentationContextfield, and discover that asymmetry by reading source — there is no single place that says "here is where observability plugs in."MetricsContextis the sharper version of the same problem. Its SPI shape — what instruments it builds, where it gets injected, whether it is per-operation likehttp_tracer_factoryor pipeline-scoped like the spanTracer— was frozen before any caller existed to validate it. The obvious first emitters (request-duration histogram, retry-attempt counter) have no home that matches either tracing seam, which is exactly the signal that the contract was shaped against guesswork. Shipping a public contract with no in-tree consumer means the first real emitter may well force a breaking reshape. (The "metrics SPI has no core emitter" point is tracked separately as an implementation gap; the design-level concern here is the premature, unvalidated SPI shape and the split injection style across the whole observability surface.)Proposed direction
Unify injection on the one object the call already threads end to end:
InstrumentationContext, which already carrieshttp_tracer_factory.tracer(and, once it has a real consumer,metrics_context) fields toInstrumentationContext, each defaulting to its no-op singleton.TracingPolicythen readsctx.call.instrumentation_context.tracerinstead of taking a constructor argument, matching howHttpTraceris already resolved and collapsing two seams into one.RetryPolicyrecording an attempts counter,OperationTracingPolicyrecording operation duration throughctx.call.instrumentation_context.metrics_context. Whichever seam the emitter needs is then the seam the SPI commits to. If we are not ready to add an in-core emitter, the alternative is to move the metrics SPI into the sibling backend package (alongside the real OTel implementation) until an in-core consumer exists, rather than ship a frozen contract with no caller in this repo.Trade-offs of this direction:
InstrumentationContext, a frozen dataclass, and couples the tracer/metrics lifetime to the trace context. That coupling is acceptable since both are already per-operation concerns that travel with the trace, and it is strictly more consistent than the status quo.TracingPolicyconstructor is a public-API change for anyone currently passingtracer=; it would want a deprecation path or a release note.Open question for discussion
Do we want all observability handles to live on
InstrumentationContext(one seam, per-operation lifetime), or do we deliberately want span-tracer configuration to stay at pipeline-build time? Either is defensible, but the current mix of both for the same concern is the part worth resolving — and it should be resolved before the metrics contract gains an external consumer that locks its shape in.