Repository navigation
fix(telemetry): rebuild the worker on MicroVM identity refresh - #20953
litianningdatadog wants to merge 1 commit into
Conversation
Codeowners resolved asResolved from the full PR diff against |
Dependency direction analysis
|
Circular import analysis
|
❌ ErrorsYour PR has failed checks. Please review the issues below and take necessary action before merging. 🚦 2 Pipeline jobs failed
ℹ️ InfoNo other issues found (see more)🧪 All tests passed Useful? React with 👍 / 👎 This comment will be updated automatically if new data arrives.🔗 Commit SHA: da7829a | Docs | View more details | Give us feedback! |
BenchmarksBenchmark execution time: 2026-10-15 22:07:57 Comparing candidate commit da7829a in PR branch Found 0 performance improvements and 3 performance regressions! Performance is the same for 348 metrics, 9 unstable metrics, 4 known flaky benchmarks, 4 flaky benchmarks without significant changes.
|
db329bc to
adebde7
Compare
62fafe4 to
810c6a1
Compare
adebde7 to
c5961b2
Compare
810c6a1 to
df15134
Compare
f6d4c74 to
fc8abd4
Compare
df15134 to
ea2e306
Compare
fc8abd4 to
fc73ae2
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved concurrency issues can deadlock logging, lose recorder metrics, and overwrite newer telemetry state during replay.
3 open findings
What changed in this PR
Rebuilds telemetry workers after MicroVM identity refresh, extending the runtime-identity work to telemetry and shared trace-exporter metrics.
Changes:
- Recreates workers and replays configuration, integration, product, and dependency state.
- Synchronizes MicroVM worker access and updates exporter subscriptions.
- Adds refresh, retry, replay, and concurrency coverage.
| File | Description |
|---|---|
| tests/tracer/test_writer.py | Tests exporter updates and concurrent sends. |
| tests/telemetry/test_writer.py | Tests worker refresh, recovery, replay, and locking. |
| tests/telemetry/test_dependency.py | Tests dependency re-reporting after refresh. |
| tests/appsec/sca/test_telemetry.py | Updates the writer fixture for locking. |
| releasenotes/notes/fix-telemetry-worker-identity-refresh-19fe1775be48b696.yaml | Documents the telemetry identity fix. |
| ddtrace/internal/writer/writer.py | Tracks worker replacements and manages subscriptions. |
| ddtrace/internal/telemetry/writer.py | Implements refresh, state replay, and synchronization. |
| ddtrace/internal/telemetry/noop_writer.py | Adds compatible no-op lifecycle methods. |
| ddtrace/internal/telemetry/dependency.py | Resets dependency reporting status. |
| ddtrace/internal/telemetry/dependency_tracker.py | Schedules full dependency re-reporting. |
| ddtrace/internal/runtime/__init__.py | Exports callback removal. |
| ddtrace/internal/_runtime_id.py | Implements refresh callback removal. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| worker.stop(send_app_closing=False) | ||
| self._worker = None | ||
| self.started = False | ||
| _unbind_metric_recorders(self) |
There was a problem hiding this comment.
The rebind half is fixed: _rebind() no longer clears the context, and _PendingWorker sets the context before the worker, so add() can't pair a worker with None. Covered by test_metric_recorder_rebind_keeps_context_paired_with_worker.
Points recorded while the old worker's stop() drains can still reach that worker. Making MetricRecorder.add() wait for the refresh would add synchronization to the span hot path for every process, not just MicroVMs, so recorders stay lock-free. The loss is limited to the refresh window, and the PR description lists the stop() flush under the previous runtime ID as a known risk.
There was a problem hiding this comment.
the rebind half is fixed; the rest is explained as a bounded, disclosed loss.
0549be3 to
b2aaf01
Compare
fc73ae2 to
3d14424
Compare
5e34037 to
c00bdc9
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c00bdc9175
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| worker = self._worker | ||
| if worker is None: | ||
| return | ||
| worker.stop(send_app_closing=False) |
There was a problem hiding this comment.
Contain native panics during identity refresh
In a MicroVM, this native stop() call runs synchronously from the /run identity-refresh callback. A Rust panic exposed by PyO3 is a pyo3_runtime.PanicException derived directly from BaseException, while _notify_runtime_identity_refresh_callbacks() catches only Exception; consequently, a panic while stopping the restore-sensitive worker escapes into and aborts the request. The tracer's equivalent refresh path explicitly recognizes and contains this panic type, and telemetry should do the same while preserving the stale worker for the periodic retry and still allowing unrelated BaseException types to propagate.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in f28baa3. In a MicroVM, a Rust panic (pyo3_runtime.PanicException) is now contained like an exception in _refresh_runtime_identity(), which keeps the stale worker in place for the periodic retry, and in that retry (_rebuild_stale_worker_without_lock()). Other BaseExceptions still propagate. The same applies to enable()'s worker build, which can run on an app thread after a refresh whose rebuild panicked. The refresh logs the panic at WARNING, below the MicroVM log handler's ERROR level, so logging it can't wait on the enable lock it holds. Covered by test_microvm_identity_refresh_contains_native_panic_and_keeps_stale_worker, test_microvm_periodic_contains_native_panic_from_stale_worker_rebuild and test_enable_contains_native_panic_from_worker_build_only_in_microvm.
| with self._metric_lock: | ||
| self._metric_contexts.clear() | ||
| self._worker = worker |
There was a problem hiding this comment.
Recheck disablement before publishing the replacement
If disable() or app_shutdown() runs while a MicroVM refresh is inside _build_worker(), it can set _enabled to false, observe _worker as None, unregister the callback, and return; this path then publishes the newly built worker without checking _enabled again. The disabled writer is left owning a spawned native worker after its collector and shutdown path have already been torn down. Serialize disable() with this lock or recheck _enabled immediately before publishing and dispose of the unpublished worker.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in f28baa3. In a MicroVM, disable() now takes the enable lock, so it waits for a refresh that is building a worker and then stops the worker that refresh published. Outside a MicroVM, disable() is unchanged and lock-free. Covered by test_microvm_disable_waits_for_in_flight_rebuild_and_stops_replacement.
3d14424 to
b3d0d84
Compare
c00bdc9 to
3e0c865
Compare
b3d0d84 to
4467f33
Compare
3e0c865 to
5bb9819
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5bb98191c9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # Read the worker once: a MicroVM identity refresh on another thread can clear it. | ||
| if not self.enable() or (worker := self._worker) is None: |
There was a problem hiding this comment.
Serialize replayable updates with the worker swap
When an integration/configuration/product update calls enable() just before a MicroVM refresh acquires _enable_lock, it can retain the old worker and then pause before recording its replay state. The refresh can stop that worker, snapshot the old state, and publish the replacement; when the update resumes, it stores the new state but sends the event only to the stopped worker, so the replacement never receives it. The update path must either participate in the refresh lock or recheck the worker generation before sending/replaying.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in 6140e16. Integration, product and configuration updates now record their replay state before calling enable() and reading the worker. A concurrent refresh then either replays the update, or has already published the worker the update reads and sends to. While a refresh is between discard and publish, _worker is None, so enable() waits on the enable lock. The worst case is a duplicate event, which is harmless for these. Covered by test_microvm_product_update_racing_refresh_reaches_new_worker.
| Returns the reported dependency records or ``None`` when nothing was reported for testing. | ||
| """ | ||
| if not self._enabled or self._worker is None: | ||
| if not self._enabled or (worker := self._worker) is None: |
There was a problem hiding this comment.
Keep dependency refresh state paired with its worker
If the dependency collector reads the old worker here immediately before identity refresh, it can resume after _dependency_tracker.refresh(), consume the forced full report, mark every dependency sent, and enqueue that report on the already-stopped worker. The refresh thread's subsequent report against the replacement then sees nothing, leaving the new worker permanently without the dependency snapshot. Serialize collection with the refresh or verify the worker generation before consuming tracker state.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in 6140e16. In a MicroVM, _report_dependencies() now takes the enable lock, which the refresh holds throughout, so a refresh can't land between reading the worker and consuming the tracker's report. Outside a MicroVM it stays lock-free. Covered by test_dependency_report_holds_enable_lock_only_in_microvm.
| for name, value, origin, config_id in list(self._configurations): | ||
| seq_id = next(self._sequence_configurations) | ||
| worker.add_configuration( |
There was a problem hiding this comment.
Prevent replay from overflowing the native mailbox
When a MicroVM has accumulated enough replay state to fill the native worker's mailbox, this tight loop can enqueue up to 5,000 configurations before integrations and products are replayed. The binding implements add_configuration() with try_send_msg() and explicitly drops data when the bounded channel is full (src/native/telemetry.rs lines 69-75 and 319-340), yet every call still returns successfully. The refresh therefore publishes and starts a worker with silently incomplete state; replay needs a capacity-safe or acknowledged path rather than the best-effort live-update API.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The native add_* calls are best-effort by design (try_send_msg(), drop_on_err), and the mailbox holds 5000 messages. A MicroVM process with ddtrace.auto records about 260 replay messages at startup: 250 configuration events, 3 integrations and 7 products. That's about 5% of the capacity, and the replay store's 5000 bound matches libdatadog's own. A capacity-safe, acknowledged replay would need a native API change, so it's out of scope here.
5bb9819 to
f28baa3
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f28baa3021
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if was_started: | ||
| # A failed start is retried by periodic() on the next heartbeat. | ||
| self.app_started() |
There was a problem hiding this comment.
Contain panics during the periodic start retry
If the replacement worker's start() raises a PyO3 PanicException here, _refresh_runtime_identity() contains the first panic and leaves started false as intended, but the promised retry occurs through periodic() calling app_started(), whose handler catches only Exception. A repeated native panic therefore escapes the periodic target and terminates its thread instead of preserving future retries; recognize is_panic_exception() on that retry path while still allowing unrelated BaseException types to propagate.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in 6140e16. In a MicroVM, app_started() now contains a Rust panic from start() like an exception, so the retry through periodic() can't end the heartbeat thread. Other BaseExceptions still propagate, and outside a MicroVM it's unchanged. Covered by test_start_retry_contains_native_panic_only_in_microvm.
f28baa3 to
6140e16
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6140e16025
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if self._is_microvm: | ||
| # Recorded before reading the worker, as in add_integration(). | ||
| self._configurations.append((configuration_name, serialized_value, origin, config_id)) | ||
|
|
||
| if not self.enable() or (worker := self._worker) is None: |
There was a problem hiding this comment.
Do not resend a configuration replayed by enable
When an identity refresh has cleared _worker, a concurrent configuration update appends its value here and then blocks in enable(). If _replay_worker_state() snapshots that append, it sends the value to the replacement worker; after enable() returns, add_configuration() sends the same value again with another sequence ID. The current append-before-enable ordering fixes the earlier lost-update race but creates a duplicate configuration change; add_configurations() has the same problem. Serialize replay with the send or record whether the triggering update was already replayed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in db97dfc. In a MicroVM, integration, product and configuration updates now record their replay state and send it while holding the enable lock, so a refresh can't replay an update between the two. An update that finds no worker is delivered by the replay into the worker its enable() builds, and isn't sent again. That also closes the worse case, where the late send carried a newer sequence ID than an update made after it. Covered by test_microvm_update_that_builds_the_worker_is_sent_once and test_microvm_configuration_update_racing_rebuild_is_sent_once.
| if get_parent_runtime_id() is None: | ||
| # A MicroVM rebuild has already replayed the bootstrap configurations above. | ||
| if not self.started and not (self._is_microvm and self._configurations): | ||
| self.add_configurations(get_python_config_vars()) |
There was a problem hiding this comment.
Track bootstrap configuration replay explicitly
If the initial MicroVM worker build fails and a later add_configuration() call triggers the successful retry, that call has already made _configurations nonempty before entering enable(). This condition therefore skips get_python_config_vars() even though none of the bootstrap configurations were ever stored or sent, leaving the eventual app-started payload permanently incomplete. Use an explicit bootstrap-recorded flag rather than treating any saved configuration as proof that bootstrap reporting happened.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in db97dfc. In a MicroVM, the first worker build now records the bootstrap configurations at the front of the replay store, behind an explicit _bootstrap_configurations_recorded flag, and the replay sends them. An update that retries a failed first build therefore still gets them reported, and ahead of itself. Covered by test_microvm_bootstrap_configurations_precede_update_that_retries_failed_build.
There was a problem hiding this comment.
A lazy telemetry rebuild can deadlock with an agentless switch outside MicroVMs because bootstrap reporting re-enters a non-reentrant enable lock after the switch clears the worker. This can indefinitely block the telemetry caller and LLMObs.enable().
🤖 Bits Code Review · Commit 6140e16
6140e16 to
3d91ccf
Compare
4467f33 to
1b08ad8
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3d91ccf245
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| else: | ||
| metadata = None | ||
| self._worker.add_dependency(name, version, metadata) | ||
| worker.add_dependency(name, version, metadata) |
There was a problem hiding this comment.
Retry the dependency snapshot after a send failure
When an identity refresh is replaying the full dependency set and any add_dependency() call raises, collect_report() has already marked every dependency as sent and cleared _report_all. The refresh callback failure is then isolated, and a later periodic() starts the replacement without retrying the failed or remaining dependencies, leaving that worker with an incomplete dependency snapshot. Keep the refresh report pending until every native call succeeds, or restore the tracker's refresh state when a send fails.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The native add_dependency() doesn't raise when a send fails. It goes through drop_on_err(), which logs and drops the datum when the channel is full or the worker has stopped, and returns normally (src/native/telemetry.rs). Only a fork-runtime error or a Rust panic could raise there. Marking dependencies as sent before the native call is also how every report already works on main, since a call that returns can still be dropped natively. Keeping the refresh report pending until delivery is confirmed would need an acknowledged native path, so I'm leaving this as is.
| self._discard_worker("while refreshing runtime identity") | ||
| self._dependency_tracker.refresh() | ||
| # The old worker is already discarded; do not report refresh success when enable() fails. | ||
| if not self.enable(): |
There was a problem hiding this comment.
Retry endpoint replay when the refresh aborts
During this refresh, enable() publishes the replacement before _report_endpoints() finishes. If that replay raises—for example, request-time route registration mutates the live endpoint_collection.endpoints set while it is being iterated—the callback failure is isolated but the current-runtime worker remains installed. Subsequent enable() and periodic() calls therefore never replay the remaining pre-refresh endpoints, leaving the replacement's endpoint snapshot permanently incomplete; replay from a stable snapshot and retain a pending/retry state until it succeeds.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in da7829a. The realistic failure here was iterating the live endpoint_collection.endpoints set while another thread registers a route, which raises RuntimeError: Set changed size during iteration. _report_endpoints() now replays from a list(...) snapshot, so that can't happen. I didn't add a pending/retry state: once the set can't change, add_endpoint() can only raise for reasons a retry would hit again. A full channel or a stopped worker is dropped by drop_on_err() without raising. What's left is argument conversion on the same endpoint object, or a post-fork runtime error that every native call on that worker would hit. Covered by test_endpoint_replay_survives_route_registered_during_replay.
3d91ccf to
db97dfc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db97dfc85f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try: | ||
| self._worker.start() | ||
| except Exception: | ||
| except BaseException as e: |
There was a problem hiding this comment.
Serialize root worker start with identity refresh
When a root app_started() retry overlaps the /run refresh, the old worker's start() can return successfully and the thread can pause before setting self.started; the refresh then observes started == False, stops that worker, publishes a replacement without starting it, and the old thread resumes and marks the replacement as started. Subsequent heartbeats skip app_started(), so the replacement never emits lifecycle events or starts its heartbeat schedule. Hold _enable_lock across the root start and flag update, or verify that the worker generation is unchanged before setting started.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid. Fixed in da7829a. In a MicroVM, app_started() now holds the enable lock across start() and setting started, through an _app_started_without_lock() twin, so a refresh can't land in between. The refresh already calls app_started() under that lock, which is reentrant. Outside a MicroVM it's unchanged and lock-free. Covered by test_microvm_refresh_during_root_start_still_starts_replacement and test_app_started_holds_enable_lock_only_in_microvm.
| self._worker = None | ||
| self.started = False | ||
| _unbind_metric_recorders(self) | ||
| self._notify_worker_changed(None) |
There was a problem hiding this comment.
Make worker subscription atomic with refresh
When a concurrent Tracer.configure() rebuild reaches _get_shared_worker() while /run is swapping workers, it can capture the old worker and pause; the refresh then sends both the None and replacement notifications, after which the new trace exporter installs the stopped handle and subscribes too late to receive either notification. That exporter permanently drops its trace_api.* health metrics because there may be no further worker change. Return-and-subscribe to the current worker atomically with the refresh, or recheck the generation after registering the subscriber.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This is one of the known gaps, #20956 covers that.
…20952) Stacked PRs: - #19778 — runtime identity foundation (merged) - #19898 — generic WSGI/ASGI/Django request-start event (merged) - #19939 — MicroVM `/run` listener and identity refresh (merged) - This PR #20952 — tracer: rebuild the trace writer (part 1 of #19820) - #20953 — telemetry: rebuild the worker (part 1 of #19821) - #20954 — registers the `/run` listener; runtime-metrics runtime-ID tag (part 1 of #19822) - #20957 — runtime metrics: reset collector baselines (part 2 of #19822) - #20956 — telemetry: discard the old worker and exporter subscription (part 2 of #19821) - #20955 — tracer: drop trace state from before the refresh (part 2 of #19820) Part 1 PRs (#20952, #20953, #20954) can ship first. Part 2 PRs (#20955, #20956, #20957) can ship later. **Release coupling:** #20952 is intended to ship together with #20954. This PR registers the tracer as an identity-refresh subscriber; #20954 registers `maybe_refresh_identity()` on `WEB_REQUEST_STARTING_EVENT`, which is what makes the `/run` request trigger the refresh in production. Do not release #20952 without #20954. ## Description This PR and #20955 split #19820, so the runtime-ID fix can ship before the discard logic. This PR makes the tracer report the new runtime ID after a MicroVM identity refresh. #20955 drops trace state from before the refresh. The native trace exporter copies the runtime ID when it's built (`set_runtime_id(get_runtime_id())` in `_build_base_exporter_builder()`), so after `refresh_identity()` the tracer keeps reporting the snapshot's runtime ID until its writer is rebuilt. The root span's `runtime-id` tag already reads `get_runtime_id()` for each span and needs no change. - `ddtrace/trace/__init__.py` registers `tracer._refresh_runtime_identity` with `on_runtime_identity_refresh()` inside a MicroVM check. - `Tracer._refresh_runtime_identity()` rebuilds the writer the way the post-fork path does (`_recreate(reset_buffer=False, flush_writer=False)`), then stores the tracer metadata again, in a `finally`, so a failed rebuild still records the new runtime ID. A failed rebuild is retried on the next span start. A Rust panic from the rebuild is logged instead of re-raised, because the refresh dispatcher only contains `Exception` and the panic would otherwise abort the `/run` request. The span-start retry contains its own failures in a MicroVM: an `Exception` or Rust panic from the rebuild is logged (rate-limited) and the retry stays pending, so it doesn't fail the app's span start. `KeyboardInterrupt` and `SystemExit` still propagate, and outside a MicroVM a failed post-fork rebuild still raises, as on `main`. - The refresh and `shutdown()` serialize on `_post_fork_lock`. `shutdown()` sets `_shutting_down` under that lock, so it waits for a refresh in flight and stops the writer that refresh installs, and a refresh that starts after shutdown builds nothing. The lock is now reentrant, because the exit-signal handler can run `shutdown()` on the thread that is refreshing. If that handler returns, the refresh sees `_shutting_down` after `_recreate()` and stops the writer it just installed, shutting down the exporter of a `NativeWriter` that never started. - In a MicroVM, `shutdown(timeout)` waits at most `timeout` (clamped at 0) for `_post_fork_lock`, then sets `_shutting_down` either way. Without the bound, an exit signal could deadlock: a refresh on another thread holds `_post_fork_lock` while waiting for the old writer's `_writer_lock`, which the thread running the signal handler holds. A refresh still running after the wait stops the writer it installs, through the same `_shutting_down` check. `shutdown()` without a timeout still waits as before. The span-start retry of a failed refresh (`_ensure_post_fork_writer()`) does the same in a MicroVM: it builds nothing once `_shutting_down` is set, and stops a writer it installs after that wait. Outside a MicroVM the post-fork rebuild is unchanged. The rebuild stops the old writer the normal way, so the old writer still flushes what it buffered. A span finished on another thread after that stop would restart the old writer through the lazy start in `_write_with_client()`, leaving a writer that nothing stops. So the refresh first closes the old writer to new spans, using #19820's MicroVM-only write gate: `_writer_lock` and `_accepting_writes` on `HTTPWriter` and `NativeWriter`, checked in `write()`. `TraceWriter._stop_accepting_writes()` closes the gate without dropping anything. #20955 replaces it with `drop_buffered_traces()`. How a refresh runs, and what a span finished on another thread sees meanwhile: ```mermaid sequenceDiagram participant Run as /run thread participant T as Tracer participant Old as old writer participant New as new writer participant App as span-finishing thread Run->>T: _refresh_runtime_identity(new_id) Note over T: holds _post_fork_lock (reentrant) until the end opt shutdown already started T-->>Run: return, build nothing end T->>Old: _stop_accepting_writes() (under the old writer's _writer_lock) T->>Old: _recreate(reset_buffer=False, flush_writer=False) stops it, flushing what it buffered T->>New: built by recreate() with the new runtime ID T->>T: _store_metadata() in a finally, so the new runtime ID is recorded even if the rebuild failed opt shutdown() ran during the rebuild T->>New: stop the writer it just installed end Note over T: releases _post_fork_lock App->>T: span finishes, aggregator writes the trace alt before the old writer is closed T->>Old: written, and flushed when the old writer stops else after closing, before the swap T->>Old: write() returns without writing, so the closed writer is not restarted else after the swap T->>New: written under the new runtime ID end ``` Nothing registers `maybe_refresh_identity()` until #20954, so merging this PR alone doesn't change runtime behavior. Smaller changes: - `NativeWriter.write()` takes the write gate through the sync-mode flush, as `HTTPWriter.write()` does. Otherwise the refresh could close the writer between the buffering and the flush, and `recreate()` would shut down the exporter that flush then sends through. #19820 keeps the flush outside the gate and instead rechecks `_discarded_by_refresh` before each send; those rechecks belong to #20955. ## Testing `tests/tracer/runtime/test_runtime_id.py`: - `test_tracer_microvm_identity_refresh_recreates_exporter_without_fork_side_effects`: the refresh rebuilds the writer with the post-fork arguments and stores the metadata, without fork side effects. - `test_tracer_microvm_identity_refresh_closes_and_flushes_old_writer`: the old writer sends its buffer once and shuts its exporter down. A later write neither buffers nor restarts it. - `test_tracer_microvm_identity_refresh_after_shutdown_does_not_recreate_writer` - `test_tracer_microvm_identity_refresh_keeps_pending_after_recreate_failure`: a failed rebuild keeps the pending post-fork rebuild and still stores the metadata. - `test_tracer_microvm_identity_refresh_schedules_rebuild_after_recreate_failure`: a failed rebuild is left pending for the next span. `RuntimeError` and `KeyboardInterrupt` propagate; a Rust panic doesn't. - `test_tracer_identity_refresh_contains_native_panic_from_writer_rebuild`: a panic from the rebuild doesn't escape `refresh_identity()`, and other refresh subscribers still run. - `test_tracer_microvm_identity_refresh_retries_failed_writer_recreation_on_next_span`: after a failed rebuild, the next span rebuilds the writer, and the stored metadata carries the refreshed runtime ID. - `test_tracer_shutdown_waits_for_in_flight_identity_refresh`: shutdown during a refresh stops the writer the refresh installs. - `test_tracer_identity_refresh_during_shutdown_does_not_recreate_writer`: a refresh that starts while shutdown runs builds no writer. - `test_tracer_shutdown_on_refreshing_thread_stops_replacement_writer`: shutdown run on the refreshing thread (the exit-signal case) doesn't deadlock, and the replacement writer is stopped, or has its exporter shut down if it never started. - `test_tracer_signal_shutdown_does_not_deadlock_with_refresh_waiting_on_writer_lock`: shutdown run by a thread that holds the writer lock a refresh is waiting on (the cross-thread exit-signal case) returns after its timeout, and the refresh then stops the writer it installed. - `test_tracer_microvm_shutdown_accepts_negative_timeout` - `test_tracer_timed_out_shutdown_stops_writer_from_retried_refresh_rebuild`: a `shutdown(timeout=0)` that finishes while the span-start retry of a failed refresh is rebuilding still gets the rebuilt writer stopped. It fails without the fix. - `test_tracer_post_fork_rebuild_ignores_shutdown_flag_outside_microvm`: outside a MicroVM the post-fork rebuild ignores `_shutting_down`, as before. - `test_tracer_microvm_failed_rebuild_retry_is_contained_in_start_span`: in a MicroVM a span-start retry that fails again with a `RuntimeError` or Rust panic doesn't raise and stays pending, `KeyboardInterrupt` still propagates, and a later span start recovers. It fails without the fix. - `test_tracer_post_fork_rebuild_failure_still_raises_outside_microvm`: outside a MicroVM a failed post-fork rebuild still raises from the span start, as before. - `test_identity_refresh_hook_notifies_global_tracer`, `test_deferred_global_tracer_registers_identity_refresh_callback`, `test_deferred_global_tracer_does_not_register_identity_refresh_callback_outside_microvm`: the global tracer registers only in a MicroVM, including with deferred initialization. `tests/tracer/test_writer.py`: - `test_native_writer_closed_microvm_writer_rejects_writes_without_restarting`, `test_http_writer_closed_microvm_writer_rejects_writes`: a closed writer drops later spans and isn't restarted by them. Spans buffered before the close stay for the final flush. - `test_native_writer_microvm_close_waits_for_in_flight_sync_flush`: closing a sync-mode writer waits for a flush that is sending, and nothing is flushed through the closed writer. - `test_native_writer_stop_accepting_writes_is_noop_outside_microvm`, `test_log_writer_stop_accepting_writes_is_noop` `tests/tracer/test_tracer.py`: - `test_tracer_shutdown_waits_for_in_flight_post_fork_writer_recreation`: shutdown waits for a post-fork writer rebuild on another thread and stops the rebuilt writer. - `test_tracer_shutdown_timeout_does_not_bound_post_fork_wait_outside_microvm`: outside a MicroVM, a shutdown timeout doesn't cut that wait short. Each new concurrency test fails without the change it covers. Local runs (Python 3.14): - `scripts/run-tests --venv 128b106 -- tests/tracer/test_tracer.py tests/tracer/test_writer.py tests/tracer/runtime/test_runtime_id.py`: 316 passed, 13 skipped, 1 xpassed. - After rebasing onto `main` `2ad3da71cb`: `scripts/run-tests -r --venv 128b106 -- tests/tracer/runtime/test_runtime_id.py tests/tracer/test_tracer.py tests/tracer/test_writer.py -k "test_runtime_id or test_writer or shutdown or post_fork"`: 209 passed, 10 skipped, 1 xpassed. - With the shutdown-retry and retry-containment fixes (Python 3.13): `scripts/run-tests --venv 1ef5a52 -- tests/tracer/test_writer.py tests/tracer/runtime/test_runtime_id.py tests/tracer/test_tracer.py`: 320 passed, 13 skipped, 1 xpassed. - `scripts/lint fmt` and `scripts/lint typing` on the changed files: passed. Not done yet: measuring `/run` in a MicroVM with the agent stopped (see Risks). ## Risks Outside a MicroVM the callback isn't registered and `_writer_lock` is `None`, so `write()` takes the lock-free path. The one change there: `shutdown()` now takes `_post_fork_lock` once, so a shutdown that races a post-fork writer rebuild on another thread waits for it and then stops the rebuilt writer, instead of leaving it running. The shutdown timeout doesn't bound that wait outside a MicroVM. Inside a MicroVM, once #20954 registers the listener and until #20955 lands: - `/run` waits for the old writer's final flush, because `stop()` joins the writer thread. That's one send plus `exporter.shutdown()`, which can take up to 3 s. With a healthy agent that's milliseconds. With an unreachable agent it's the send's retries plus up to 3 s. - Traces and client stats buffered before `/run` are sent under the snapshot's runtime ID, once by each restored instance. - Spans that other threads finish during that window are dropped. - Spans open across `/run` keep the old root `runtime-id` tag and go to the new writer. - A writer rebuild running at the same time as the refresh (`Tracer.configure()`, LLMObs) can still install a writer built for the old runtime ID. In supported usage these rebuilds run at startup, before the snapshot. Remote config, whose AppSec activation calls `tracer.configure()` at runtime, isn't supported in MicroVMs. #20955 serializes writer swaps with the refresh. - If `_recreate()` raises, the closed old writer stays installed and drops spans until a span start retries the rebuild successfully. While the rebuild keeps failing, every span start retries it, and the warning is rate-limited to one a minute. - In the exit-signal deadlock case, `shutdown(timeout)` can take up to about 2 × `timeout`: the bounded wait for an in-flight refresh, then the normal flush. If it gives up waiting while the span-start retry of a failed refresh (`_ensure_post_fork_writer()`) holds the lock, that retry stops the writer it installs. - A Rust panic from the native metadata write still reaches `/run`, as it does from `__init__` and after a fork. The write runs after the rebuild, so it can't skip it. Unchanged from `main`, and not specific to the refresh: - An exit signal that arrives while a writer rebuild is inside the old writer's `stop()` deadlocks on that writer's `_service_lock`. `Tracer.configure()` and the post-fork rebuild have the same exposure. - Outside a MicroVM, a post-fork rebuild interrupted by a same-thread shutdown whose signal handler returns can still install a writer after shutdown. ## Additional Notes Each part 2 tip has the same tree as its original PR: #20955 matches #19820, #20956 matches #19821, and #20957 matches #19822. So the fixes made on this PR and on the originals are all in part 2. Left out on purpose: - A cap on the `/run` wait. The telemetry worker's `stop()` (#20953) also blocks `/run`, and this code sets no timeout on it, so capping the trace writer alone wouldn't bound `/run`. - #19820's span-aggregator writer-swap lock. Without the discard, it would be held across the old writer's final flush and block every span start and finish. - #19820's detach of the context active at `/run`. ## Reference - [RFC](https://docs.google.com/document/d/17lde4Zak2YRBuDvf32KFanXj9TdpOv24GYaEY8swA1w/edit?tab=t.0#heading=h.wwu37decnk8j) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: tianning.li <tianning.li@datadoghq.com>
There was a problem hiding this comment.
More details
No actionable regression was identified beyond the telemetry-drop and exporter-subscription races explicitly deferred to part 2. Missing native extensions prevented runtime validation.
🤖 Bits Code Review · Commit db97dfc
db97dfc to
df094a9
Compare
The native telemetry worker copies the runtime ID when it is built. In MicroVMs, TelemetryWriter now registers _refresh_runtime_identity(). On refresh it stops the worker built for the old runtime ID, re-schedules dependency reports, rebuilds the worker and starts it again. A failed refresh is raised, and periodic() rebuilds a worker that is still bound to an older runtime ID. A rebuilt worker starts empty, so in MicroVMs the writer keeps the latest configuration, integration and product state and replays it into every worker it builds. DependencyTracker.refresh() marks every dependency as not yet reported. The refresh swaps the worker on the /run thread while other threads may be using it, without a worker access lock: - Data paths read the worker once, so a refresh between the check and the native call cannot hand them None. A call that reaches the stopped worker is dropped by the native worker. - A metric context registered on a worker that was replaced meanwhile is not cached for the new worker. enable() publishes the worker under the metric lock, so that check and the cache write cannot straddle the swap. - The replay iterates over snapshots of the state it replays, and runs before the worker is published, so an update sent to the new worker is not overwritten by older replayed state. - Integration, product and configuration updates record their replay state and send it under the enable lock, so a refresh can't replay an update between the two and have it sent again after newer updates. An update that builds the worker is delivered by the replay rather than sent again. - In MicroVMs the bootstrap configurations are recorded once, ahead of the first replay, so every worker gets them, including one built by an update that retries a failed first build, and they precede that update. - enable() reads the runtime ID once and builds the worker with it, so the worker's runtime and session IDs match the ID periodic() compares. - The enable lock is reentrant everywhere: bootstrap reporting calls enable() under it, and an agentless switch can clear the worker in between. - Dependency reporting takes the enable lock in MicroVMs, so a refresh can't land between reading the worker and consuming the tracker's report. - app_started() takes the enable lock in MicroVMs, so a refresh can't land between start() and setting started and leave the replacement unstarted. - Endpoint replay iterates over a snapshot, so a route registered on another thread during the replay can't abort it. - MetricRecorder._rebind() no longer clears the recorder's context: add() reads the worker and the context separately, so a rebind between the two reads could hand the old worker None. In MicroVMs the telemetry log handler only takes ERROR records. It forwards them to add_log() under its handler lock, and add_log() can wait for the enable lock, which the refresh holds while it may log DEBUG. In MicroVMs a Rust panic (pyo3_runtime.PanicException, a BaseException) is contained like an exception: in the refresh, keeping the stale worker for the periodic retry; in that retry; and in enable()'s worker build, which can run on an app thread after a panicked refresh, and in app_started()'s start(), which periodic() retries: an exception escaping periodic() ends the heartbeat thread. Other BaseExceptions still propagate. If replaying state into a new worker fails, enable() stops that unpublished worker so its native task is released. disable() takes the enable lock in MicroVMs, so it waits for a refresh that is building a worker and stops the worker that refresh publishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
df094a9 to
da7829a
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: da7829aacb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| except BaseException as stop_error: | ||
| if not (isinstance(stop_error, Exception) or is_panic_exception(stop_error)): | ||
| raise | ||
| log.debug("Failed to stop the unpublished telemetry worker", exc_info=True) |
There was a problem hiding this comment.
Retain unpublished workers when cleanup fails
When replay fails because ensure_runtime_after_fork() raises or panics, this cleanup calls stop(), which enters the same guard and can fail before consuming the registration handle. The new catch then continues to return False and drops the only Python reference; src/native/telemetry.rs documents that dropping this handle leaks the worker until runtime shutdown, so repeated rebuild attempts can accumulate registered workers. This cleanup-failure branch is fresh evidence beyond the earlier replay-cleanup finding; retain the worker for a later stop attempt or provide a disposal path that consumes its registration without the failing guard.
Useful? React with 👍 / 👎.
| # worker.start() must stay under this lock: it uses the local `worker` reference built | ||
| # above, and a concurrent identity refresh (which shares this lock in MicroVMs) could | ||
| # otherwise stop/replace self._worker in between, leaving this thread starting an | ||
| # already-discarded worker and marking self.started against the wrong generation. | ||
| if get_parent_runtime_id() is None: |
There was a problem hiding this comment.
Keep post-build setup serialized with disable
When a MicroVM data path rebuilds after a failed refresh, disable() can acquire _enable_lock immediately after this block releases it, stop the newly published worker, clear the endpoint callback, and set _deps_collector to None; the rebuilding thread then resumes at the dedented setup and re-registers the callback and starts a new periodic collector on the permanently disabled writer. Keep endpoint subscription and collector creation inside the disable serialization, or recheck _enabled before performing that setup.
Useful? React with 👍 / 👎.


Stacked PRs:
/runlistener and identity refresh/runlistener; runtime-metrics runtime-ID tag (part 1 of chore(runtime): refresh runtime metric identity and collector state #19822)Part 1 PRs (#20952, #20953, #20954) can ship first. Part 2 PRs (#20955, #20956, #20957) can ship later.
Description
This PR and #20956 split #19821. This PR rebuilds the telemetry worker for the new runtime ID after a MicroVM identity refresh. #20956 adds the worker access lock, the changes to how the trace exporter gets and follows the worker, and the discard path that builds on #19820's.
The native telemetry worker copies the runtime and session IDs when it's built, so after
refresh_identity()it keeps reporting the snapshot's IDs until it's replaced.In a MicroVM,
TelemetryWriterregisters_refresh_runtime_identity(), anddisable()unregisters it through the newremove_runtime_identity_refresh().On refresh, under the enable lock, the writer:
_discard_worker());DependencyTracker.refresh());enable();app_started()again if the old worker had started.The refresh re-enters
enable(), and so does bootstrap configuration reporting, so the enable lock is anRLock. A failed stop leaves the old worker in place and is raised, and so is a failed rebuild. The writer records the runtime ID each worker was built with, and in a MicroVMperiodic()rebuilds a worker still bound to an older one, so the next heartbeat retries a refresh that failed on/run. If the rebuild itself failed, the next telemetry call builds the worker.A rebuilt worker starts with empty native state, so in a MicroVM the writer replays its last-known state into every worker
enable()builds:Outside a MicroVM the writer keeps none of this state and replays nothing, so worker rebuilds there (agentless switch, test token, payload directory) behave as before.
The refresh replaces the worker on the
/runthread while other threads may be using it. This PR doesn't lock the data paths (#20956 does). Instead:self._workeronce, so a refresh between the check and the native call can't hand themNone. A call that reaches the stopped worker is dropped by the native worker.enable()starts the worker (in forked children) under the enable lock, so a refresh can't replace the worker between building and starting it. Outside a MicroVM it also reports the bootstrap configurations there; in a MicroVM the replay carries them._register_metric_context()doesn't cache a context registered on a worker that was replaced in the meantime.enable()clears the context cache and publishes the worker under the metric lock, so that check and the cache write can't straddle the swap.enable()builds, not sent again.enable()reads the runtime ID once and builds the worker with it, so the worker's runtime and session IDs match the IDperiodic()compares.app_started()takes the enable lock, so a refresh can't land betweenstart()and settingstarted, which would leavestartedset for a replacement that was never started.MetricRecorder._rebind()no longer clears the recorder's context.add()reads the worker and the context separately, so a rebind between the two reads (a refresh on another thread) could hand the old workerNoneand raiseTypeErrorfrom span start or finish. The no-op and pending workers ignore the stale context.add_log(), which can wait for the enable lock. The refresh can log DEBUG while holding the enable lock, so a DEBUG record must never wait for the handler lock.pyo3_runtime.PanicException, which derives fromBaseException) is contained like an exception: in the refresh, which keeps the stale worker for the periodic retry; in that retry; inenable()'s worker build, which can run on an app thread after a refresh whose rebuild panicked; and inapp_started()'sstart(), whichperiodic()retries, because an exception that escapesperiodic()ends the heartbeat thread. OtherBaseExceptions still propagate, and outside a MicroVM nothing changes. If replaying state into a new worker fails,enable()stops that unpublished worker, which releases its native task, and returnsFalse.disable()takes the enable lock, so it waits for a refresh that is building a worker and stops the worker that refresh publishes, instead of returning while_workerisNone.How a refresh runs, and what another thread sees meanwhile:
sequenceDiagram participant Run as /run thread participant W as TelemetryWriter participant Old as old worker participant New as new worker participant App as another thread Run->>W: _refresh_runtime_identity(new_id) Note over W: holds the enable lock until the end W->>Old: stop() (flushes once under the old runtime ID) W->>W: _worker = None, unbind recorders, notify(None) W->>W: DependencyTracker.refresh() W->>New: _build_worker() (new runtime and session IDs) W->>New: replay configurations, integrations, products W->>W: publish _worker = New (under the metric lock), bind recorders, notify(New) W->>New: start() via app_started(), if the old worker had started Note over W: releases the enable lock App->>W: product_activated() / add_*() alt read _worker before the discard App->>Old: call reaches the stopped worker and is dropped (known gap, closed in part 2) else _worker is None mid-refresh W-->>App: enable() waits on the enable lock, then sends to New after the replay else after the publish App->>New: sends normally endNothing registers
maybe_refresh_identity()until #20954, so merging this PR alone doesn't change runtime behavior.Known gaps, closed by #20956's worker access lock and exporter changes:
_downgrade()) can get the old worker and subscribe after both worker-change notifications have fired. Itstrace_api.*metrics then go to the stopped worker and are dropped, not reported under the old runtime ID. On/runthe tracer and telemetry refreshes run one after the other on the same thread, so this needs an exporter rebuild on another thread at the same moment.NoOpTelemetryWritergets a no-op_refresh_runtime_identity().Testing
tests/telemetry/test_writer.py:test_microvm_identity_refresh_rebuilds_worker_with_new_runtime_id: the rebuilt worker has the new runtime and session IDs and the replayed integration, product and configuration state.test_microvm_identity_refresh_emits_new_runtime_id: end to end against the test agent.test_microvm_identity_refresh_stops_legacy_worker_without_drop,test_microvm_identity_refresh_stop_failure_propagates: the old worker is stopped, and a failed stop keeps it and is raised.test_microvm_identity_refresh_retries_failed_rebuild,test_microvm_identity_refresh_failed_replacement_start_recovers_on_periodic,test_microvm_periodic_retries_failed_discard,test_periodic_skips_stale_worker_check_outside_microvmtest_microvm_identity_refresh_reports_bootstrap_configurations_once,test_configuration_replay_store_is_microvm_only,test_integration_and_product_replay_store_is_microvm_onlytest_microvm_identity_refresh_replay_does_not_overwrite_concurrent_update: a product change made on another thread during the replay is the last state the new worker gets. It fails if the worker is published before the replay.test_microvm_error_handler_skips_records_below_error: in a MicroVM a DEBUG record never reaches the handler; outside one the handler is unchanged.test_microvm_identity_refresh_contains_native_panic_and_keeps_stale_worker: a Rust panic from the old worker's discard doesn't escape the refresh and keeps the stale worker for the retry;KeyboardInterruptstill propagates.test_microvm_periodic_contains_native_panic_from_stale_worker_rebuild: a Rust panic while the heartbeat retries the stale worker doesn't escapeperiodic(), and the next heartbeat rebuilds it.test_microvm_disable_waits_for_in_flight_rebuild_and_stops_replacement:disable()during a refresh rebuild waits for it and stops the worker the refresh publishes.test_enable_contains_native_panic_from_worker_build_only_in_microvm: a Rust panic building the worker is contained in a MicroVM and still raises outside one.test_microvm_product_update_racing_refresh_reaches_new_worker: a refresh that runs between an update's worker read and its replay record doesn't lose the update.test_dependency_report_holds_enable_lock_only_in_microvm: dependency collection holds the enable lock in a MicroVM and not outside one.test_start_retry_contains_native_panic_only_in_microvm: a panickingstart()retried byperiodic()doesn't escape it in a MicroVM, and still raises outside one.test_microvm_replay_failure_stops_unpublished_worker: a replacement whose replay fails with an exception or a Rust panic is stopped, and the refresh reports the failure.test_microvm_update_that_builds_the_worker_is_sent_once(4 cases): an update that builds the worker reaches it once, through the replay.test_microvm_configuration_update_racing_rebuild_is_sent_once: a configuration update made while a refresh builds the replacement reaches it once.test_microvm_bootstrap_configurations_precede_update_that_retries_failed_build: when an update retries a failed first build, the bootstrap configurations are still reported, and before the update.test_enable_builds_worker_with_the_runtime_id_it_records: the worker's runtime and session IDs equal the recorded ID even if the ID rotates during the build.test_bootstrap_reporting_reenters_enable_after_worker_is_cleared: outside a MicroVM, bootstrap reporting re-entersenable()instead of deadlocking when an agentless switch clears the worker.test_microvm_refresh_during_root_start_still_starts_replacement: a refresh that lands whileapp_started()is starting the old worker still starts the replacement.test_app_started_holds_enable_lock_only_in_microvm:app_started()holds the enable lock in a MicroVM and not outside one.test_endpoint_replay_survives_route_registered_during_replay: a route registered during the endpoint replay doesn't abort it.test_metric_recorder_rebind_keeps_context_paired_with_worker: a rebind betweenadd()'s two reads doesn't hand the old workerNone.test_metric_context_registered_on_replaced_worker_is_not_cached: a context registered on a worker that was replaced in the meantime isn't cached for the new one.test_non_microvm_writer_does_not_register_identity_refreshtests/telemetry/test_dependency.py:test_refresh_preserves_and_rereports_dependency_metadata,test_refresh_rereports_dependencies_when_sca_disabled.Local runs:
telemetry(venv70966a9, Python 3.14) ontests/telemetry/test_writer.py tests/telemetry/test_dependency.py tests/appsec/sca/test_telemetry.py: 216 passed.scripts/lint styleonddtrace/internal/telemetry/andtests/telemetry/test_writer.py, andscripts/lint typingonddtrace/internal/telemetry/writer.py: passed.Risks
Low outside a MicroVM. The refresh callback is never registered there, and nothing is kept or replayed for worker rebuilds. What does apply everywhere:
enable()now starts the worker (forked children) and reports the bootstrap configurations under the enable lock, and publishes the worker under the metric lock. Both happen once per worker build.RLock, because bootstrap reporting callsenable()under it and an agentless switch can clear the worker in between. It's only taken while no worker exists.enable()reads, instead of reading it again for each field.self._workerinto a local instead of reading it twice.MetricRecorder._rebind()keeps the old context. Rebinds are rare, and the stale context only reaches workers that ignore it.DependencyTracker.collect_report()checks one more flag per heartbeat.Inside a MicroVM:
stop(), which flushes its queued telemetry and app-closing under the previous runtime ID. fix(telemetry): rebuild worker on identity refresh #19821 does the same today, because the bundledTelemetryWorkerhas no non-flushingdrop().TelemetryWorker.stop()blocks until the worker has drained, with no timeout in this code, so/runalso waits for that send.Additional Notes
#20956's tip has exactly the code of #19821. #20956 holds:
_without_locktwins, which replace this PR's single worker reads, metric-context cache check, metric-lock publish and replay snapshots. With the lock comesadd_log()formatting before the lock;_get_shared_worker();_subscribe_worker_changes(callback, expected_worker, late_callback), publish-before-subscribe inNativeWriter._create_exporter(), and the matching_downgrade()change;drop()branch in_discard_worker().This PR doesn't touch
ddtrace/internal/writer/writer.py.Reference
🤖 Generated with Claude Code