Repository navigation
fix(telemetry): shut down the retired tracer provider off async workers - #10276
Conversation
✅ Docs preview has no changesThe preview was not built because there were no changes. Build ID: 8f6544d4484ffc819b7d5892 ✅ AI Style Review — No Changes DetectedNo MDX files were changed in this pull request. Review Log: View detailed log
|
68c085c to
830544b
Compare
Prepares for the factory to hold state across the routers it creates. No behaviour change.
…hread Every tracer and span holds a clone of its tracer provider, and the provider shuts down when the last clone is dropped. A span that straddles a reload could hold that last clone, so the batch span processor shutdown ran on an async worker and could park it permanently, keeping the retired pipeline alive. The router factory that the state machine keeps for its whole lifetime now owns a handle to the installed tracer provider and lends it to each telemetry activation. Commit swaps the new provider into the handle and shuts the retired one down explicitly in the activation's blocking cleanup, so later drops do nothing. A reload that leaves tracing unchanged, or one that fails before commit, leaves the installed provider in place. When the state machine stops, cleanly or on error, it asks the factory to shut down the final provider on a blocking thread. Also correct the comment above the global swap, and note why meter providers do not need the same treatment.
f65eab5 to
900c9e4
Compare
| block_in_place(move || opentelemetry::global::set_tracer_provider(tracer_provider)); | ||
|
|
||
| // Store the retired provider so that Drop shuts it down on a blocking thread. | ||
| self.new_trace_provider = retired; |
There was a problem hiding this comment.
Commit-time provider swap is never exercised by tests. Neither new test runs reload_tracing. The first one sets activation.new_trace_provider = Some(retired) by hand, and OPENTELEMETRY_TRACER_HANDLE is unset in unit tests, so the installed.replace(...) plus self.new_trace_provider = retired swap never runs. If this line were dropped, or the wrong provider went into the handle, both tests would still pass. The old provider would then leak, or the live one would be shut down on every reload.
There was a problem hiding this comment.
Fixed. The handle swap is now in its own method, Activation::swap_installed_tracer_provider. It takes the new provider, records it in the handle and keeps the replaced one as the retired provider. reload_tracing calls it and only adds the hot-tracer reload and the global set_tracer_provider, which rely on process globals that unit tests can't set up. No statics were added.
The new test committing_a_tracer_provider_retires_the_installed_one runs that swap against a handle that already holds an installed provider, then drops the activation. It checks that the old provider was shut down exactly once, that the new one is still live, and that the handle's own shutdown later stops the new one. I checked that it fails for both mutations you described: if the handle isn't updated, or if the new provider is retired instead of the old one, the test fails with "the newly installed provider must stay live".
Resolved by 86b841be9767.
| } | ||
| // Release what the factory keeps across routers, such as the installed tracer provider. | ||
| // This runs whether the router stopped cleanly or because of an error. | ||
| self.router_configurator.shutdown().await; |
There was a problem hiding this comment.
No test checks that the state machine calls factory shutdown. Nothing asserts that process_events calls router_configurator.shutdown() or that OrbiterRouterSuperServiceFactory passes the call on. MockMyRouterConfigurator quietly uses the empty default. executable.rs no longer resets the global tracer provider, so this call is now the only exit-time flush. If it regresses, the last batch of spans is lost on every shutdown and no test fails.
There was a problem hiding this comment.
Added coverage for both parts.
router_factory_is_shut_down_when_the_state_machine_stopsrunsprocess_eventswith a factory that passescreatethrough to the existing mock and countsshutdowncalls. It has two cases: a clean stop (startup, thenShutdown, returningOk) and an error exit (the factory fails on startup, returningErr). Both cases assert thatshutdownran exactly once.orbiter::test::shutdown_is_forwarded_to_the_router_factorywraps aYamlRouterFactoryholding an installed tracer provider inOrbiterRouterSuperServiceFactory. It callsshutdown()and asserts the provider was shut down before the factory is dropped. That covers both Orbiter's forwarding and the real factory's shutdown.
With the state machine call or the Orbiter delegation removed, all three tests fail.
Resolved by fd69101c4099.
|
|
||
| if apollo_telemetry_initialized { | ||
| // We should be good to shutdown OpenTelemetry now as the router should have finished everything. | ||
| // The router's state machine has already shut down its tracer provider. |
There was a problem hiding this comment.
The exit-time tracer flush now depends on process_events finishing. With the unconditional teardown removed, spans are flushed only if the state machine reaches its post-loop shutdown() call. If the state machine task panics (router/mod.rs turns the JoinError into StartupError), or the RouterHttpServer future is dropped early, YamlRouterFactory::shutdown never runs. Only the meter provider is shut down here, so buffered BatchSpanProcessor spans are never flushed.
There was a problem hiding this comment.
Fixed with a drop guard owned by the lifecycle, without statics. YamlRouterFactory now holds its handle through a TracerProviderOwner, and dropping the owner shuts down whatever provider the handle still holds, on a blocking thread. The explicit shutdown() at the end of process_events still runs first on normal exits and empties the handle, so the guard then does nothing. If the state machine task panics or is cancelled first, the future is dropped, so the factory and its owner are dropped too, and the final provider is still shut down.
owner_shuts_down_the_installed_provider_if_its_task_panics_or_is_cancelled puts an owner inside a spawned task and checks that the provider is shut down exactly once, both when the task panics and when it is aborted. With the guard's shutdown removed, the test fails.
An early drop of RouterHttpServer sends the shutdown signal and leaves the task running, so it still reaches the explicit shutdown. If the runtime itself is shutting down, the task is cancelled and the guard takes over. One limit: the guard's blocking task isn't awaited, so if the process exits immediately afterwards the flush can be cut short. The normal exit path still awaits it.
Resolved by 8d3118eafd00.
| license: Arc<LicenseState>, | ||
| ) -> Result<Self::RouterFactory, BoxError>; | ||
|
|
||
| /// Releases state that outlives individual routers. Called once when the state machine stops, |
There was a problem hiding this comment.
"After its last router has been dropped" isn't enforced. The Reloading path discards all_connections_stopped_signals ("Connections from before this attempt will not be awaited on shutdown"), so old pipelines can still be serving requests when this runs. Their spans then end on a provider that is already shut down and are silently dropped. Either enforce this or relax the doc comment.
There was a problem hiding this comment.
I'm Ok with dropping some span if it is very complex to to the right thing.
Think about this and make a judgment call.
There was a problem hiding this comment.
I relaxed the doc comment rather than enforcing the guarantee. The comment now says that shutdown runs once when the state machine stops, whether it stopped cleanly or because of an error. It also says shutdown doesn't wait for every retired pipeline: connections from before a failed reload attempt aren't tracked, and a fatal reload error stops the router without draining it, so spans those pipelines end after this call are dropped.
Enforcing it would mean carrying the connection-stopped signals through failed reload attempts (try_start consumes them today) and draining on the fatal-error path too. That changes state machine behaviour that has nothing to do with this fix. The only cost is losing some late spans from old pipelines at process exit, which you said is acceptable.
Resolved by b12591cd6646.
|
|
||
| /// Shuts down a tracer provider. This blocks until its span processors have shut down, so it MUST | ||
| /// be called from a blocking thread. | ||
| fn shutdown(tracer_provider: SdkTracerProvider) { |
There was a problem hiding this comment.
Shutdown failures are logged only at debug!. YamlRouterFactory::shutdown also only logs a JoinError. So a failed flush of the final batch at exit (exporter timeout or unreachable) loses spans with nothing logged at info or above. Meter provider shutdown in executable.rs logs the same kind of failure with tracing::error!.
There was a problem hiding this comment.
tracing::warn seems more appropriate for both of these.
There was a problem hiding this comment.
Both now log at warn!: the tracer provider shutdown error in activation.rs, and the JoinError from the exit shutdown task in YamlRouterFactory::shutdown.
Resolved by c774ba696149.
| pub(crate) struct YamlRouterFactory { | ||
| /// The tracer provider installed by the routers this factory creates. Each router's telemetry | ||
| /// plugin retires the previous provider when it installs a new one. | ||
| tracer_provider: TracerProviderHandle, |
There was a problem hiding this comment.
Altitude: a static would do what this plumbing does. This threads a TracerProviderHandle through the factory, a new RouterSuperServiceFactory::shutdown trait method, Orbiter delegation and two lend call sites. activation.rs already keeps process-global state in a static (REGISTRY), and OPENTELEMETRY_TRACER_HANDLE is global too. A static INSTALLED_TRACER_PROVIDER: Mutex<Option<SdkTracerProvider>> swapped in reload_tracing, and shut down from executable.rs's existing spawn_blocking, would cover every activation path. Today, any activation that isn't lent a handle silently falls back to the original bug: the last span drop runs shutdown on an async worker.
There was a problem hiding this comment.
No, we do not want statics. Statics are a code smell.
There was a problem hiding this comment.
We're keeping the lifecycle-owned design and not adding a static. The installed provider belongs to the router's lifecycle: the factory that the state machine owns builds every router and outlives them. A process global would hide that ownership, and it would be shared by every router in the process, including test routers.
Your underlying concern was right, though: an activation that was never lent a handle quietly fell back to the original bug. That fallback is now gone structurally. The handle reaches Telemetry through its construction path, as a crate-internal PluginInit field that the router factory sets for every plugin it builds. Telemetry keeps it and passes it to Activation::commit, which now requires it. Nothing is lent afterwards, the activation has no optional handle, and there's no ordering requirement. The other thread has the details. The exit flush is also covered now if the state machine task panics or is cancelled, by a drop guard on the factory's handle, still without a static.
Resolved by 8d3118eafd00, a46deb5232a7.
| .get("apollo.telemetry") | ||
| .and_then(|plugin| plugin.as_any().downcast_ref::<Telemetry>()) | ||
| { | ||
| telemetry.lend_installed_tracer_provider(&self.tracer_provider); |
There was a problem hiding this comment.
Two lend call sites. For the initial telemetry plugin this call does nothing, because create() already lent the handle and activated it. Any future path that builds and activates a Telemetry plugin has to remember to lend first, or it silently brings back the shutdown-on-async-worker bug. Lending in one place, such as at Telemetry construction or through a global, would remove that ordering requirement.
There was a problem hiding this comment.
Fixed. The handle is now supplied when the plugin is constructed, not lent afterwards.
PluginInithas a crate-internaltracer_providerfield. Nothing changes in the public builder API.- The router factory sets it on every
PluginInitit builds, both for the early telemetry plugin at startup and inadd_plugin.create_pluginsandadd_pluginnow take the handle as a required argument. Telemetry::newstores the handle, andTelemetry::activatepasses it toActivation::commit(&handle), which requires it.
lend_installed_tracer_provider, Activation::with_installed_tracer_provider and the optional handle field are all removed. So is the redundant lend in inner_create_supergraph. A Telemetry plugin can no longer be activated without a handle, and nothing needs to happen between construction and activation. A PluginInit built outside the router factory, for example in a unit test, gets a fresh handle of its own.
Resolved by a46deb5232a7.
A failed shutdown loses the spans it was flushing, so report it at warn, both for the provider's own error and for a failed exit shutdown task.
Every telemetry plugin now receives the router's installed tracer provider handle through PluginInit when it is constructed, and Activation::commit requires it. This replaces lending the handle to a plugin after it was built, which had to happen before activation at two call sites and silently fell back to the old behaviour when missed.
Factor the handle swap out of reload_tracing, which also depends on the process-wide hot tracer and global provider, and test it directly. The new test fails if the handle is not updated or if the wrong provider is retired.
…ine is torn down The router factory now owns its handle through a TracerProviderOwner, whose drop shuts down the installed provider on a blocking thread. The final provider is then still shut down when the state machine task panics or is cancelled before it reaches its explicit exit shutdown.
The state machine does not track connections from before a failed reload attempt, and a fatal reload error stops without draining, so the old doc claim that shutdown runs after the last router is dropped was not true. Spans those pipelines end afterwards are dropped.
The state machine must call the factory's shutdown whether it stops cleanly or because of an error, and Orbiter must forward that call to the router factory, which shuts down its installed tracer provider. Both are now asserted; the test helpers for counting provider shutdowns are shared.
| } | ||
| } | ||
|
|
||
| impl Drop for TracerProviderOwner { |
There was a problem hiding this comment.
This Drop is what's failing the 25 CI tests. TestHarness::build_common (test_harness.rs:321) builds through a temporary YamlRouterFactory::default(). inner_create_supergraph activates telemetry, which installs the provider into this owner's handle. The temporary is then dropped at the end of the statement, and this Drop shuts down the provider that the global and the hot tracer are still using. Every later span is discarded, which is why apollo_reports, apollo_otel_traces and apollo_otel_http_proxy time out with 0 reports. TestHarness is public, so downstream users lose spans too. The response_cache and connectors helpers have the same pattern, where the local factory drops when the helper returns.
There was a problem hiding this comment.
Fixed by the redesign. The installed tracer provider no longer belongs to a router factory, so dropping one can't shut it down. TracerProviderOwner and TracerProviderHandle are gone. The provider now lives in the hot tracer handle behind OPENTELEMETRY_TRACER_HANDLE, next to the tracer it feeds, and that handle lives exactly as long as the process-wide subscriber. TestHarness and the response_cache and connectors helpers build their temporary YamlRouterFactory as before, with no effect on the live provider.
Locally, apollo_reports, apollo_otel_traces and apollo_otel_http_proxy now match dev. Out of 42 tests, the previous head failed 33, with timeouts; this head fails only the same 10 that also fail on unchanged dev, and those fail because this sandbox can't resolve the demo subgraph hosts.
Resolved by 2a620bdaaf49, 6a7cf666a76a.
| /// Owns the router's [`TracerProviderHandle`] and shuts down the provider it holds when dropped. | ||
| /// | ||
| /// The router factory shuts the installed provider down explicitly when the state machine stops. | ||
| /// This covers the cases where that never happens, such as the state machine task panicking or |
There was a problem hiding this comment.
The production cancel path isn't covered. off_async_workers uses block_in_place under cfg(test) but spawn_blocking in release builds, so the new panic/cancel test only exercises the test-only branch. In production the state machine future is usually dropped during runtime shutdown, and spawn_blocking then returns a cancelled handle without running the closure. That means the "cancelled" case described here still doesn't flush.
There was a problem hiding this comment.
You're right that the guard's spawn_blocking doesn't run during runtime shutdown. The redesign removes the drop guard and off_async_workers entirely. The final provider is shut down once more by the executable's existing awaited spawn_blocking at exit, next to the meter provider shutdown, as it was before this PR. The only difference is that it now takes the installed provider out of the hot tracer handle and calls shutdown() on it, where before it swapped in a default provider. So there's no untested production-only cancel path any more.
Resolved by 2a620bdaaf49.
|
|
||
| /// The router's handle to the tracer provider it has installed, for use by the telemetry | ||
| /// plugin ONLY. The router factory supplies its own; otherwise each plugin gets a fresh one. | ||
| pub(crate) tracer_provider: TracerProviderHandle, |
There was a problem hiding this comment.
An unowned default handle silently brings back the original bug. builder, try_builder and fake_builder all default this to a fresh, unowned handle, and the "telemetry plugin ONLY" note isn't enforced. A Telemetry plugin built through any other path retires nothing on commit, so set_tracer_provider drops the previously installed provider. If a span holds the last reference, shutdown runs on an async worker, which is the stall this PR is fixing.
There was a problem hiding this comment.
Fixed by removing the field. PluginInit is back to how it is on dev, with no tracer provider handle and no builder defaults. There is now only one place that can install a tracer provider: TracerHandle::install, on the process-wide hot tracer handle. It returns the provider it replaces, and Activation keeps that as the retired provider and shuts it down off the async workers. However a Telemetry plugin is built, its activation goes through that same install operation, so there's no unowned handle to fall back to.
Resolved by 2a620bdaaf49.
| /// The tracer provider installed by the routers this factory creates. Every plugin this factory | ||
| /// creates is given its handle, so each telemetry plugin retires the previous provider when it | ||
| /// installs a new one. Dropping the factory shuts down the installed provider. | ||
| tracer_provider: TracerProviderOwner, |
There was a problem hiding this comment.
This ties a global provider's lifetime to one factory instance. The installed provider is process-global (it lives in opentelemetry::global and OPENTELEMETRY_TRACER_HANDLE), but its lifetime now depends on whichever factory installed it. The plumbing has spread to PluginInit, add_plugin, create_plugins and test fixtures, and any shorter-lived factory kills the live provider, which is what broke CI. A static INSTALLED_TRACER_PROVIDER next to REGISTRY, swapped in reload_tracing and shut down at state-machine or executable teardown, would match the real scope and remove the handle from PluginInit.
There was a problem hiding this comment.
Agreed on scope: the provider is process-wide, and tying it to one factory was the mistake. This follows your scope point, but rather than adding a new static it uses the existing hot tracer handle behind OPENTELEMETRY_TRACER_HANDLE. That handle is already set once per process, alongside the subscriber. It now holds both the reloadable tracer and the provider it came from. TracerHandle::install builds the scoped tracer, hot-swaps it, sets the global in block_in_place and returns the replaced provider. The executable shuts the final provider down at exit.
All the plumbing is gone: the handle in PluginInit, the arguments to add_plugin and create_plugins, the handle stored in Telemetry, the argument to commit, RouterSuperServiceFactory::shutdown with the state machine call and the Orbiter forwarding, and the fixture edits, including the YamlRouterFactory::default() refactor. Against dev, the PR now touches only reload/otel.rs, reload/activation.rs, executable.rs and the changeset.
Resolved by 2a620bdaaf49, 6a7cf666a76a.
The installed tracer provider is process-wide, like the subscriber and the hot tracer it feeds, so a router factory could not own it: a temporary factory shut down the live provider, and plugins built elsewhere got an unowned handle. The hot tracer handle now keeps the provider, and its one install operation returns the provider it replaces for the activation to shut down. The executable shuts the final provider down at exit. The factory handle, its PluginInit field and the shutdown trait method go.
The factory no longer holds the tracer provider, so it is a unit struct again and clippy rejects YamlRouterFactory::default(). This restores the original call sites and changes no behaviour.
Two activations could interleave inside TracerHandle::install, leaving the hot tracer on a provider that the other activation then shut down, so spans were discarded until the next reload. The install lock is now held from the tracer hot-swap through recording the provider and setting the global. Spans read the hot tracer through its own lock and never wait on it. The regression test pauses one install through a test-only hook, waits for a second install to start and checks that the install lock is still held before letting the first continue.
|
This pull request does not currently match the merge queue conditions, so it cannot be queued from here. The box comes back if it matches again. |
…ig files The weekly forward-port of dev into dev-v3.x committed conflict markers. This resolves the files outside the Rust sources: - .gitleaks.toml: keep both allowlisted commits (the 3.x redaction-test credentials and the dev cache-key fixture). - Cargo.lock: keep the dev-v3.x side of each conflicted dependency list and point the thiserror references at 2.0.21, the version dev bumped to. Cargo accepts the result unchanged, and it carries every dev dependency bump (async-compression, tokio-rustls, hyper-util, aws-*, rand, lru, ...). opentelemetry-zipkin stays removed as on dev-v3.x. - apollo-federation/CHANGELOG.md: dev released 2.16.4 and moved the @deprecated shim and satisfiability dedup entries into it. Keep that, and keep the 3.x-only entries (default value validation, @OneOf, the @interfaceObject fix, the shape notation and connectors validation changes) in the unreleased 2.18.x section instead of under 2.16.4, where the conflict had placed them. - .changesets/fix_retired_tracer_provider_shutdown.md: removed. Its code (dev #10276) is not forward-ported because dev-v3.x already shuts down the replaced tracer provider on reload and at exit (#9910). - .changesets/fix_traffic_shaping_pool_idle_timeout_inheritance.md: use the 3.x subgraph key `http2` in the example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dev brought connectors response caching (#9171), connector-declared errors (#10160), the pool_idle_timeout fix (#10315) and a reload fix for the tracer provider (#10276). dev-v3.x had meanwhile rebuilt the pipeline in acquire/activate/assemble phases, moved telemetry and traffic shaping into per-stage layers, switched plugin stages to BoxCloneService, added the circuit breaker, and redacted secrets in config. Each conflict keeps the dev behaviour on the 3.x structure. Conflicted files: - services/connector/request_service.rs: keep 3.x Request::test_new and dev's is_part_of_batch. - plugin/mod.rs: keep 3.x doc link paths, renamed to Response::transport_outcome. dev's new PluginPrivate::connector_service stage takes and returns connect::BoxCloneService. - services/connector_service.rs, services/supergraph/service.rs: keep 3.x (the supergraph builder moved to pipeline/stages.rs). The plugins' connector stage is wired in build_connector_services instead, wrapping each connector's ConnectorService. - plugins/telemetry/mod.rs: keep 3.x. dev's connector-declared error counting moves into the connector telemetry layer, and the connector cache instruments become Telemetry::instrument_connector_cache_layer (new telemetry/layers/connect.rs), applied in build_connector_services. - plugins/telemetry/reload/{otel,activation}.rs, executable.rs: keep 3.x. dev #10276 is left out: 3.x (#9910) already shuts the replaced provider down on a blocking thread during reload and at exit. - plugins/include_subgraph_errors/mod.rs: keep the 3.x layers; dev's request-side EffectiveConfig insertion, which the connectors plugin reads when mapping declared errors, moves into RedactSubgraphErrorsLayer. - plugins/connectors/handle_responses.rs: dev's TransportOutcome, with 3.x's IncompleteResponseBody marker set on TransportOutcome::Response. - plugins/traffic_shaping/mod.rs: keep the 3.x layer constructors; dev's From<..> for shared::Client conversions written out per block because subgraphs use the 3.x `http2` key and connector sources keep `experimental_http2`. dev's hook-based rate limit tests are dropped: 3.x covers rate limiting at the layer level. - plugins/response_cache/plugin.rs: dev's connector storages without activate() (3.x storages no longer need it); `subgraph` becomes optional with dev's disabled default via #[config(default)]; InvalidationService gets the connector config. - plugins/limits/mod.rs, telemetry/config_new/instruments.rs, connectors/tests/public_plugin.rs: import merges. Merged cleanly but needed porting: - dev replaced Response::transport_result with TransportOutcome; the 3.x circuit breaker and its tests, and the traffic shaping timeout/admission tests, use TransportOutcome. - response_cache/connectors.rs and the connector hooks use BoxCloneService (the buffers dev added only made BoxService cloneable); connector cache config drops Serialize because it holds Redis credentials, with schema defaults declared by hand as 3.x does for subgraphs; responses set break_status. - response_cache/invalidation_endpoint.rs: connector shared keys use the 3.x constant-time shared_key_matches (an empty key never matches). - configuration/shared/mod.rs: the Client builder takes `http2`. - Tests: Redacted shared keys, declared_errors/break_status fields, extended_error_metrics, the 3.x `http2` subgraph key, PipelineFactory instead of YamlRouterFactory. - Configuration schema snapshot regenerated: the connector cache types now resolve to the same definitions as their subgraph counterparts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
A router that hot-reloads often, for example on every supergraph publish, can occasionally keep a retired pipeline in memory for good. The router keeps serving traffic, but memory steps up by the size of the old pipeline (plan cache, Redis connection pools and so on) and never comes back. The retired pipeline's Redis pool keeps heartbeating, and connections for the old schema drain to zero. So nothing legitimate is keeping it alive.
The cause is where the replaced OpenTelemetry tracer provider gets shut down.
SdkTracerholds a clone of itsSdkTracerProvider, and so does every SDKSpanthrough its tracer. The provider shuts itself down when its last clone is dropped.tracingspan (OpenTelemetryLayer::on_close). If that span is built just before a reload swaps the tracer, and dropped just after, it holds the last reference to the old provider.BatchSpanProcessorshutdown sends a message to its background task and then callsfutures_executor::block_onto wait for the reply, ignoring its timeout. Tokio puts the woken background task in that worker's LIFO slot, which other workers cannot steal. The worker waits for a task that only it could run, and parks forever.The reload path already tried to guard against this. It wrapped
set_tracer_providerinblock_in_place, and its comment claimed the old provider "is returned and must be dropped in a blocking task". In 0.31,set_tracer_providerreturns(): it drops the replaced value itself, and nothing was ever handed back.block_in_placemade the global's own drop safe, but it never covered clones held by in-flight spans. The same race reproduces with opentelemetry 0.24 and the earlier guard, so it predates the 0.31 upgrade.Fix
The router now keeps its own reference to the tracer provider it has installed. Installing a new one hands back the provider it replaces, and the reload shuts that provider down explicitly, on a blocking thread, before any straddling span lets go of it. Once a provider is shut down,
TracerProviderInner::dropseesis_shutdownand does nothing, whichever thread drops the last clone.Where the installed provider lives
The installed provider is process-wide, like everything it feeds. The tracing subscriber is installed once per process, and so are the OpenTelemetry global and the hot-swappable tracer the subscriber exports through (
OPENTELEMETRY_TRACER_HANDLE). So the provider is kept in that existing hot tracer handle, next to the tracer built from it, and no new static is added. The handle is a small type,TracerHandle, inreload/otel.rs:flowchart LR S[tracing subscriber, once per process] -->|exports spans through| R[ReloadTracer] H[OPENTELEMETRY_TRACER_HANDLE: TracerHandle] -->|owns| R H -->|keeps| P[installed SdkTracerProvider] R -->|current tracer built from| P G[OTel global provider] -->|clone of| P A[Activation, one per reload] -->|install new provider, keep returned one| H E[executable exit] -->|take and shut down| HTracerHandle::install(provider)is the only way to install a tracer provider. It builds the scoped tracer and hot-swaps it into the subscriber, records the provider, and sets the global inblock_in_placeas before. It returns the provider it replaced.Activation::reload_tracingcallsinstalland keeps the returned provider as the retired one.Activation's existingDrop, which already runs its cleanup inspawn_blocking(block_in_placeundercfg(test)), now callsshutdown()on it rather than just dropping it. Whoever installs a provider is handed the old one to shut down, so this holds for every activation, however its Telemetry plugin was built.spawn_blockingtakes the final provider out of the handle and shuts it down, next to the meter provider shutdown. Before, it swapped in a default provider for the same purpose.ReloadTraceris unchanged.Reload lifecycle
sequenceDiagram participant A as Activation (reload) participant H as TracerHandle participant G as OTel global participant B as Blocking thread participant W as Async worker (straddling span) alt tracing config changed A->>H: install(new provider) H->>H: hot-swap tracer, record new provider H->>G: set_tracer_provider(new) in block_in_place Note over G: drops its clone of old, not the last one H-->>A: returns old provider A->>B: Activation drop: old.shutdown() W->>W: drops span, last clone of old Note over W: already shut down, returns immediately else tracing unchanged Note over A,H: nothing to install, handle keeps the current provider else reload fails before commit A->>B: Activation drop: shuts down its never-installed provider Note over H: installed provider untouched end Note over B,H: at exit, executable takes the installed provider from H and shuts it down in spawn_blockingAn earlier revision of this PR kept the provider in the router factory and passed a handle to it through
PluginInit,add_plugin,create_pluginsand a new factoryshutdown()method. That tied a process-wide provider's lifetime to one factory: dropping a temporary factory (asTestHarnessdoes) shut down the live provider, and plugins built elsewhere got a handle nobody owned. All of that is removed, including theYamlRouterFactory::default()refactor, which is reverted in its own commit. Replies posted on the earlier review threads about the factory handle, the factoryshutdown()and its tests, the drop guard and the factory doc comment describe that removed design.Meter providers
I audited meter providers for the same last-clone pattern, and they are not affected. SDK meters and instruments hold the provider's pipelines (
Arc<Pipelines>), notSdkMeterProviderInner, and only that type'sDroptriggers shutdown. The router'sAggregateMeterProvideris the only holder of eachSdkMeterProvider, andsethands the replaced one back to theActivation, which drops it on a blocking thread. No request path can own the last reference, so meter providers are unchanged apart from a comment recording this.How it was verified
The tests use a span processor that counts how many times its provider is shut down, and a local
TracerHandle, not the process-wide one.installing_a_tracer_provider_retires_the_one_it_replacesinstalls a first provider (nothing is returned), starts a span on it to stand in for the straddling span, and commits a second provider through anActivation. The first provider must be shut down exactly once while the span is still alive, the new one must stay live, and taking and shutting down the installed provider at exit must then stop the new one.retired_tracer_provider_is_shut_down_while_a_span_still_holds_itchecks that dropping anActivationshuts down its retired provider while a span holds it, and that the span's later drop doesn't shut it down again.activations_that_do_not_replace_the_installed_provider_leave_it_runningcovers an unchanged-tracing reload and a failed reload. Neither may shut down the installed provider.Each key assertion was checked by breaking the code:
installnot returning the replaced provider, the activation retiring the new provider instead of the old one, andDroponly dropping the retired provider. Each makes the tests fail.The
apollo_reports,apollo_otel_tracesandapollo_otel_http_proxyintegration tests, which failed on the previous revision because a dropped temporary factory shut down the live provider, now behave as they do ondev. Only the process-wide hot tracer reload and the global install are not unit-tested, because they are one-time process globals. An earlier standalone model of the reload path, built outside the router, retained no pipelines across 60,000 reloads with explicit shutdown of the retired provider. Without it, a pipeline was retained within a few reloads.Risk
tracingspans build their SDK span on close with whichever tracer is current, so long-running requests that straddle a reload still export through the new provider.TestHarnessor embedded without that setup don't touch the hot tracer handle.warn.Where to look first
TracerHandle::installinreload/otel.rs. It records the new provider before setting the global, and it returns the replaced provider. That order is what guarantees that the global's drop of the old provider is never its last reference and that no span can hold the last clone of a provider that hasn't been shut down.Activation::install_tracer_providerandDrop for Activationinreload/activation.rs, which keep the returned provider and shut it down off the async workers.