Skip to content

tracing spans never exported — the SDK bridged events but not spans - #91

Merged
brentrager merged 1 commit into
mainfrom
tracing-otel-bridge
Aug 17, 2026
Merged

tracing spans never exported — the SDK bridged events but not spans#91
brentrager merged 1 commit into
mainfrom
tracing-otel-bridge

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

bootstrap() installs the OTel SDK and sets the global tracer provider. That is enough for spans opened through the OpenTelemetry API. It does nothing for tracing spans — #[instrument], info_span!, and every library built on them — because those need a tracing-opentelemetry layer inside the installed subscriber, and this crate never offered one.

The crate already had the event half: tracing_appender_layer() turns tracing events into OTLP logs. The span half was simply missing, and nothing said so.

Found in production (th-eaccd1)

A service exported its OTel-API spans fine while every tracing span — including the per-turn gen_ai.chat span carrying model and token usage, the single most valuable span in an LLM product — was printed to stdout and never left the process.

Provider installed. Spans opened. Dashboards green. Five weeks of nothing.

Verified by driving a real turn through a live public agent: the agent answered, 467 WebSocket frames came back, and zero rows landed.

The fix

OtelSdkHandle::tracing_span_layer() behind feature tracing-bridge, deliberately shaped like the existing tracing_appender_layer(): the crate hands the host a layer, the host owns its subscriber.

That sidesteps the ordering hazard which makes this class of bug so durable — tracing_subscriber's init() panics if a subscriber already exists, so an SDK that installs one races every vendor helper that does the same, and loses silently.

Bound to this handle's provider rather than the global one, so it exports through the same pipeline the handle flushes and shuts down.

Three tests, and the middle one is the point

Test Asserts
a_tracing_span_reaches_the_exporter the span arrives, by name
without_the_bridge_layer_a_tracing_span_exports_nothing the negative control
span_attributes_survive_the_bridge gen_ai.request.model + token usage survive

Without the negative control, the first test proves only that spans exist somewhere — which is exactly the false confidence being fixed here.

Compiles with and without the feature; 3/3 pass.

Follow-up (separate, in the monorepo): chat-ws must compose this layer. It currently calls smooth_operator::init_telemetry() first, which installs a fmt-only subscriber and permanently claims the slot.

`bootstrap()` installs the OTel SDK and sets the global tracer provider. That is
enough for spans opened through the OpenTelemetry API. It does NOTHING for
`tracing` spans — `#[instrument]`, `info_span!`, and every library built on them
— because those need a `tracing-opentelemetry` layer inside the installed
subscriber, and this crate never offered one.

The crate already had the EVENT half: `tracing_appender_layer()` turns tracing
events into OTLP logs. The SPAN half was simply missing, and nothing said so.

Found in production (th-eaccd1). A service exported its OTel-API spans fine
while every `tracing` span — including the per-turn `gen_ai.chat` span carrying
model and token usage, the single most valuable span in an LLM product — was
printed to stdout and never left the process. Provider installed, spans opened,
dashboards green, five weeks of nothing. Verified by driving a real turn through
a live public agent: the agent answered, 467 WebSocket frames came back, and
zero rows landed.

Adds `OtelSdkHandle::tracing_span_layer()` behind feature `tracing-bridge`,
deliberately shaped like the existing `tracing_appender_layer()`: the crate hands
the host a LAYER, the host owns its subscriber. That sidesteps the ordering
hazard that makes this class of bug so durable — `tracing_subscriber`'s `init()`
PANICS if a subscriber already exists, so an SDK that installs one races every
vendor helper that does the same, and loses silently.

Bound to this handle's provider rather than the global one, so it exports through
the same pipeline the handle flushes and shuts down.

Three tests, and the middle one is the point:
  - a `tracing` span reaches the exporter
  - WITHOUT the layer, the same span exports NOTHING — the negative control.
    Without it the first test proves only that spans exist somewhere, which is
    exactly the false confidence being fixed
  - attributes survive the crossing (a span that exports its name but drops
    `gen_ai.request.model` and token usage is useless for LLM tracing)

Compiles with and without the feature; 3/3 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7f1ba38

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/observability Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager merged commit b4ba6c4 into main Aug 17, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant