Skip to content

fix(client): release the global OTel tracer provider on shutdown - #126

Open
XieX wants to merge 3 commits into
xie/agent-skillsfrom
xie/fix-otel-globals-on-shutdown
Open

XieX wants to merge 3 commits into
xie/agent-skillsfrom
xie/fix-otel-globals-on-shutdown

Conversation

@XieX

@XieX XieX commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Companion to launchdarkly/js-ai-sdk#103, which fixes the same lifecycle bugs in JS. Two of them exist here, and one has a Python-specific counterpart.

1. An init/shutdown/init cycle exported nothing

shutdown() dropped its provider handle but left OpenTelemetry's global tracer provider registered. That global is once-guarded — a second set_tracer_provider logs Overriding of current TracerProvider is not allowed and keeps the provider already in place — so after a re-init, every span routed to the provider that had just been shut down. Observed across two cycles: the global service.name stayed cycle1 and now correctly becomes cycle2.

Releasing it means resetting both the global slot and the Once that guards it; clearing the slot alone leaves the guard tripped, so the next set is a silent no-op. Both are private, since opentelemetry-python has no public way to unset them, so _release_otel_globals's docstring explains the reach. The propagator needs no reset — set_global_textmap is a plain assignment.

The release only happens when our set_tracer_provider actually took effect (trace.get_tracer_provider() is provider). Bugbot caught that the first version gated it on having built a provider, which would have wiped a host app's already-registered provider; the JS PR had the same flaw. A refused set now logs a warning.

2. A failed telemetry setup left a half-initialized client behind

_client is not None is the idempotency guard, but _client was assigned before _setup_telemetry ran. A malformed OTEL_EXPORTER_OTLP_TIMEOUT makes setup raise, and _client stayed set: the next init_client() returned the client as a silent success with no telemetry, and on the SDK-key path its connection was never closed. _client is now assigned only after setup succeeds. On the SDK-key path the client we built is closed on failure; a BYOC client is left open, since the caller owns it. JS's counterpart was a cached failed-init promise.

Not a bug here: BYOC idempotency

JS checked the singleton below the pre-initialized-client branch and re-registered OTel on every initClient(client). _resolve_client checks it first — 1 _setup_telemetry call across three inits — and two tests pin that ordering.

Verification

  • 8 new tests; each one covering a bug here was confirmed to fail against the code it fixes. The end-to-end cycle and env-var tests drive the real _setup_telemetry, with the OTLP exporter stubbed or failing before any network use.
  • Full suite 1356 pass. ruff check, ruff format, and mypy are clean.

🤖 Generated with Claude Code


Note

Overview
Fixes init/shutdown/init so telemetry works on the second cycle: shutdown() and test reset now release the process-global OTel tracer provider (via _release_otel_globals, clearing OTel’s private slot and Once guard) only when _owns_otel_globals is true—i.e. this SDK’s set_tracer_provider actually won. If another library registered first, shutdown still shuts down the provider we built but does not clear the host’s global, and a warning is logged when LD’s telemetry config is not in effect.

Init hardening: the singleton _client is set only after _setup_telemetry succeeds (BYOC and SDK paths). Failed telemetry no longer leaves a “successful” half-init; SDK-path failures close the new LDClient before re-raising. BYOC clients are never closed on failed init.

Docs in agents.md describe shutdown’s OTel release. Tests add restore_otel_globals, coverage for repeat-init idempotency, failed telemetry, full two-cycle service.name, and foreign-provider safety.

Reviewed by Cursor Bugbot for commit d1e89e6. Bugbot is set up for automated code reviews on this repo. Configure here.

`shutdown()` dropped its provider handle but left OpenTelemetry's global
tracer provider registered. That global is once-guarded — a second
`set_tracer_provider` logs "Overriding of current TracerProvider is not
allowed" and keeps the provider already in place — so an init/shutdown/init
cycle left every later span routed to the provider that had just been shut
down, and exported nothing.

Release both the global slot and the `Once` that guards it on teardown;
clearing the slot alone leaves the guard tripped and the next set a no-op.
Only released when telemetry actually started, so a process where
`_setup_telemetry` bailed keeps whatever another library registered. The
global text map propagator needs no equivalent — `set_global_textmap` is a
plain assignment, so the next setup overwrites it.

`_reset_for_testing` does the same, so a suite that initializes more than once
does not leave every later span on the first test's provider.

Also covers the BYOC idempotency this SDK already had: `_resolve_client`
checks the singleton ahead of the pre-initialized-client path, so a repeat
`init_client(options, client)` neither re-runs telemetry setup nor swaps the
stored client. The JS SDK had that check below the BYOC branch and was
re-registering OTel on every call; these tests pin the ordering here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@XieX
XieX marked this pull request as ready for review October 1, 2026 20:13

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d5ee277. Configure here.

Comment thread packages/client/src/launchdarkly_ai_server/lifecycle.py Outdated
…tered

Addresses Bugbot on #126. The previous commit gated the global teardown on
`_tracer_provider` being set, which says we *built* a provider, not that we own
the global. `_setup_telemetry` assigns the handle after `set_tracer_provider`,
whose set is refused when another library got there first — so in a process
with an existing provider (auto-instrumentation, an APM agent, or an app that
configures its own), `shutdown()` cleared that provider and reset the `Once`,
leaving the global a no-op proxy and silently killing the host application's
tracing.

Track whether our set actually took, via `trace.get_tracer_provider() is
provider`, and gate the release on that. The provider is still shut down either
way since we built it and it owns an exporter and a batch timer. Also warn when
the set is refused: the caller's `otlpEndpoint`/`serviceName` cannot take
effect, and the only existing signal is OTel's own terse warning.

The same flaw was in the JS change this ports from; fixed there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX added a commit to launchdarkly/js-ai-sdk that referenced this pull request Oct 1, 2026
The previous commit gated the global teardown on `tracerProvider` being
non-null, which says we *built* a provider, not that we own the global
registration. `setupTelemetry` assigns the handle and then calls `register()`,
whose global set is refused when another library got there first — so in a
process with an existing provider (auto-instrumentation, an APM agent, or an
app that configures its own), `shutdownTelemetry()` wiped that provider and
left the global a noop, silently killing the host application's tracing.

Read the delegate back after `register()` to learn whether the set actually
took, and gate the release on that. The provider is still shut down either way
since we built it and it owns an exporter and a batch timer. Also warn when the
registration is refused: the caller's `otlpEndpoint`/`serviceName` cannot take
effect, and OTel's own diag error is swallowed by default.

Caught by Bugbot on the Python port of this change
(launchdarkly/python-ai-sdk#126); the same flaw was here.

The test mock now models OTel's first-registration-wins semantics rather than
reporting a fixed delegate, including the unregistered noop provider that
carries no `_delegate` at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_client is not None` is `_resolve_client`'s idempotency guard, but `_client`
was assigned before `_setup_telemetry` ran. When setup raised — a malformed
`OTEL_EXPORTER_OTLP_TIMEOUT` does, with a ValueError from the OTLP exporter —
`_client` stayed set. The next `init_client()` returned that half-initialized
client as a silent success with no telemetry, hiding the config error, and on
the SDK-key path the LD client's connection was never closed. That contradicts
`init_client`'s documented promise that a call which raises leaves no global
state behind.

Assign `_client` only after setup succeeds, on both paths. On the SDK-key path
close the client we built when setup fails; on the BYOC path leave it open,
since the caller owns it.

Found in a pass over the lifecycle guards following the ownership fix; the JS
SDK's counterpart was a cached failed-init promise, fixed in
launchdarkly/js-ai-sdk#103.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants