Skip to content

fix(telemetry): rebuild the worker on MicroVM identity refresh - #20953

Open
litianningdatadog wants to merge 1 commit into
mainfrom
tianning.li/3-4-2part-1-telemetry-identity-refresh
Open

litianningdatadog wants to merge 1 commit into
mainfrom
tianning.li/3-4-2part-1-telemetry-identity-refresh

Conversation

@litianningdatadog

@litianningdatadog litianningdatadog commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Stacked PRs:

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, TelemetryWriter registers _refresh_runtime_identity(), and disable() unregisters it through the new remove_runtime_identity_refresh().

  • On refresh, under the enable lock, the writer:

    • stops the old worker (_discard_worker());
    • re-schedules dependency reports (DependencyTracker.refresh());
    • rebuilds the worker with enable();
    • calls 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 an RLock. 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 MicroVM periodic() 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:

    • the latest integration and product-activation state;
    • accepted configuration events, in order, with fresh sequence IDs (bounded at 5000, like libdatadog's store). The bootstrap configurations are recorded once, ahead of the first replay, so every worker gets them, and before any update made while no worker existed;
    • every dependency, re-reported even with SCA disabled.

    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 /run thread while other threads may be using it. This PR doesn't lock the data paths (#20956 does). Instead:

  • Data paths read self._worker once, so a refresh between the check and the native call can't hand them None. 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.
  • The replay iterates over snapshots of the state it replays, and runs before the worker is published. A caller that reads the new worker sends after the replay, so older replayed state can't overwrite its update.
  • In a MicroVM, integration, product and configuration updates record their replay state and send under the enable lock, so a refresh can't replay an update between the two and have it sent again, with a newer sequence ID, after later updates. An update that finds no worker is delivered by the replay into the worker its 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 ID periodic() compares.
  • In a MicroVM, dependency reporting takes the enable lock, so a refresh can't land between reading the worker and consuming the tracker's report, which would send the full report it schedules to the stopped worker.
  • In a MicroVM, app_started() takes the enable lock, so a refresh can't land between start() and setting started, which would leave started set for a replacement that was never started.
  • Endpoint replay iterates over a snapshot of the endpoint collection, 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 (a refresh on another thread) could hand the old worker None and raise TypeError from span start or finish. The no-op and pending workers ignore the stale context.
  • In a MicroVM, the telemetry log handler only takes ERROR records. While holding its handler lock, it forwards ERROR records to 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.
  • In a MicroVM, a Rust panic (pyo3_runtime.PanicException, which derives from BaseException) is contained like an exception: in the refresh, which keeps the stale worker for the periodic retry; in that retry; in enable()'s worker build, which can run on an app thread after a refresh whose rebuild panicked; and in app_started()'s start(), which periodic() retries, because an exception that escapes periodic() ends the heartbeat thread. Other BaseExceptions 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 returns False.
  • In a MicroVM, 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 _worker is None.

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
    end
Loading

Nothing 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:

  • Telemetry recorded while the refresh runs can reach the stopped worker and be dropped.
  • An exporter built on another thread during a refresh (for example by _downgrade()) can get the old worker and subscribe after both worker-change notifications have fired. Its trace_api.* metrics then go to the stopped worker and are dropped, not reported under the old runtime ID. On /run the 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.

NoOpTelemetryWriter gets 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_microvm
  • test_microvm_identity_refresh_reports_bootstrap_configurations_once, test_configuration_replay_store_is_microvm_only, test_integration_and_product_replay_store_is_microvm_only
  • test_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; KeyboardInterrupt still propagates.
  • test_microvm_periodic_contains_native_panic_from_stale_worker_rebuild: a Rust panic while the heartbeat retries the stale worker doesn't escape periodic(), 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 panicking start() retried by periodic() 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-enters enable() instead of deadlocking when an agentless switch clears the worker.
  • test_microvm_refresh_during_root_start_still_starts_replacement: a refresh that lands while app_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 between add()'s two reads doesn't hand the old worker None.
  • 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_refresh

tests/telemetry/test_dependency.py: test_refresh_preserves_and_rereports_dependency_metadata, test_refresh_rereports_dependencies_when_sca_disabled.

Local runs:

  • telemetry (venv 70966a9, Python 3.14) on tests/telemetry/test_writer.py tests/telemetry/test_dependency.py tests/appsec/sca/test_telemetry.py: 216 passed.
  • scripts/lint style on ddtrace/internal/telemetry/ and tests/telemetry/test_writer.py, and scripts/lint typing on ddtrace/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.
  • The enable lock is an RLock, because bootstrap reporting calls enable() under it and an agentless switch can clear the worker in between. It's only taken while no worker exists.
  • The worker is built with the runtime ID enable() reads, instead of reading it again for each field.
  • The data paths read self._worker into 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:

  • The old worker is stopped with 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 bundled TelemetryWorker has no non-flushing drop().
  • TelemetryWorker.stop() blocks until the worker has drained, with no timeout in this code, so /run also waits for that send.
  • The known gaps above.

Additional Notes

#20956's tip has exactly the code of #19821. #20956 holds:

  • the worker access lock and the _without_lock twins, which replace this PR's single worker reads, metric-context cache check, metric-lock publish and replay snapshots. With the lock comes add_log() formatting before the lock;
  • the stale-worker check in _get_shared_worker();
  • _subscribe_worker_changes(callback, expected_worker, late_callback), publish-before-subscribe in NativeWriter._create_exporter(), and the matching _downgrade() change;
  • parking a worker change that arrives during a stuck send;
  • unsubscribing the exporter on teardown and on fix(tracer): isolate MicroVM identity refresh #19820's drop paths;
  • the native drop() branch in _discard_worker().

This PR doesn't touch ddtrace/internal/writer/writer.py.

Reference

🤖 Generated with Claude Code

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

ddtrace/internal/_runtime_id.py                                         @DataDog/apm-core-python
ddtrace/internal/runtime/__init__.py                                    @DataDog/apm-sdk-capabilities
ddtrace/internal/telemetry/dependency.py                                @DataDog/apm-python
ddtrace/internal/telemetry/dependency_tracker.py                        @DataDog/apm-python
ddtrace/internal/telemetry/metrics.py                                   @DataDog/apm-python
ddtrace/internal/telemetry/noop_writer.py                               @DataDog/apm-python
ddtrace/internal/telemetry/writer.py                                    @DataDog/apm-python
releasenotes/notes/fix-telemetry-worker-identity-refresh-19fe1775be48b696.yaml  @DataDog/apm-python
tests/appsec/sca/test_telemetry.py                                      @DataDog/asm-python
tests/telemetry/test_dependency.py                                      @DataDog/apm-core-python @DataDog/apm-python
tests/telemetry/test_writer.py                                          @DataDog/apm-core-python @DataDog/apm-python

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 167 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 167 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=121)
ddtrace.internal.ci_visibility.api._base -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=119)
ddtrace.llmobs._integrations.bedrock -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=119)
ddtrace.llmobs._integrations.google_adk -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=119)
ddtrace.llmobs._integrations.base -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=119)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 1 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector

@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Pipelines  Tests

❌ Errors

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 2 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | debugging/debugger — 🔄 Retry may pass, looks flaky

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [arm64, cp310-cp310, v133965516-8f84bce-musllinux_1_2_aarch64, 1]

View more details · View in GitLab

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: da7829a | Docs | View more details | Give us feedback!

@pr-commenter

pr-commenter Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-10-15 22:07:57

Comparing candidate commit da7829a in PR branch tianning.li/3-4-2part-1-telemetry-identity-refresh with baseline commit 8b7678b in branch main.

📊 Benchmarking dashboard

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.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:httppropagationextract-b3_single_headers

  • 🟥 execution_time [+1.137µs; +1.212µs] or [+16.605%; +17.704%]

scenario:otelspan-start

  • 🟥 execution_time [+2.531ms; +3.725ms] or [+9.743%; +14.338%]

scenario:recursivecomputation-shallow

  • 🟥 execution_time [+58.228µs; +61.006µs] or [+8.268%; +8.662%]

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:coreapiscenario-context_with_data_listeners

  • unstable execution_time [-745.259ns; +726.383ns] or [-7.000%; +6.823%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-59.742ns; +19.195ns] or [-8.545%; +2.746%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-2114.514ns; +1712.673ns] or [-10.546%; +8.542%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-2189.401ns; +1801.944ns] or [-11.288%; +9.290%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-466.025ns; +295.121ns] or [-10.725%; +6.792%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-273.612ns; +182.677ns] or [-10.069%; +6.722%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-120.725ns; +75.237ns] or [-8.402%; +5.236%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-5844.763ns; +3896.001ns] or [-11.504%; +7.668%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-1116.264ns; +840.043ns] or [-10.526%; +7.921%]

Known flaky benchmarks

These benchmarks are marked as flaky and will not trigger a failure. Modify FLAKY_BENCHMARKS_REGEX to control which benchmarks are marked as flaky.

scenario:httppropagationinject-ids_only

  • 🟥 execution_time [+2.219µs; +2.337µs] or [+16.219%; +17.085%]

scenario:span-start

  • 🟥 execution_time [+0.915ms; +1.195ms] or [+7.891%; +10.308%]

scenario:telemetryaddmetric-1-count-metric-1-times

  • 🟥 execution_time [+158.040ns; +193.597ns] or [+7.930%; +9.714%]

scenario:tracer-small

  • 🟥 execution_time [+45.235µs; +46.753µs] or [+18.259%; +18.872%]

Known flaky benchmarks without significant changes:

  • scenario:errortrackingflasksqli-baseline
  • scenario:flasksimple-iast-get
  • scenario:sethttpmeta-all-enabled
  • scenario:telemetryaddmetric-record-100-metrics

@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-3-2part-1-tracer-identity-refresh branch from db329bc to adebde7 Compare October 13, 2026 19:10
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from 62fafe4 to 810c6a1 Compare October 13, 2026 19:10
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-3-2part-1-tracer-identity-refresh branch from adebde7 to c5961b2 Compare October 13, 2026 19:25
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from 810c6a1 to df15134 Compare October 13, 2026 19:25
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-3-2part-1-tracer-identity-refresh branch 2 times, most recently from f6d4c74 to fc8abd4 Compare October 14, 2026 15:39
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from df15134 to ea2e306 Compare October 14, 2026 17:35
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-3-2part-1-tracer-identity-refresh branch from fc8abd4 to fc73ae2 Compare October 14, 2026 17:35
@litianningdatadog
litianningdatadog requested a balanced review from Copilot October 14, 2026 17:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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.

Comment thread ddtrace/internal/telemetry/writer.py Outdated
Comment thread ddtrace/internal/telemetry/writer.py Outdated
Comment on lines +482 to +485
worker.stop(send_app_closing=False)
self._worker = None
self.started = False
_unbind_metric_recorders(self)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the rebind half is fixed; the rest is explained as a bounded, disclosed loss.

@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch 2 times, most recently from 0549be3 to b2aaf01 Compare October 14, 2026 20:27
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-3-2part-1-tracer-identity-refresh branch from fc73ae2 to 3d14424 Compare October 14, 2026 20:27
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch 2 times, most recently from 5e34037 to c00bdc9 Compare October 15, 2026 14:04
@litianningdatadog
litianningdatadog marked this pull request as ready for review October 15, 2026 14:16
@litianningdatadog
litianningdatadog requested review from a team as code owners October 15, 2026 14:16
@litianningdatadog
litianningdatadog requested review from juanjux and removed request for a team October 15, 2026 14:16
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T21:51:23.572477Z da7829a New commits
🔒 Security Review ✅ Completed 2026-10-15T21:48:33.213788Z da7829a New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@datadog-prod-us1-5 datadog-prod-us1-5 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bits Code Review: PASS

More details

Static review supports the refresh failure recovery and state replay ordering. The documented refresh concurrency gaps remain deferred to #20956.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit c00bdc9

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +362 to +364
with self._metric_lock:
self._metric_contexts.clear()
self._worker = worker

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-3-2part-1-tracer-identity-refresh branch from 3d14424 to b3d0d84 Compare October 15, 2026 15:10
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from c00bdc9 to 3e0c865 Compare October 15, 2026 15:10
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-3-2part-1-tracer-identity-refresh branch from b3d0d84 to 4467f33 Compare October 15, 2026 15:58
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from 3e0c865 to 5bb9819 Compare October 15, 2026 15:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +568 to +569
# Read the worker once: a MicroVM identity refresh on another thread can clear it.
if not self.enable() or (worker := self._worker) is None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +306 to +308
for name, value, origin, config_id in list(self._configurations):
seq_id = next(self._sequence_configurations)
worker.add_configuration(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from 5bb9819 to f28baa3 Compare October 15, 2026 16:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread ddtrace/internal/telemetry/writer.py
Comment on lines +1079 to +1081
if was_started:
# A failed start is retried by periodic() on the next heartbeat.
self.app_started()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread ddtrace/internal/telemetry/writer.py Outdated
Comment thread ddtrace/internal/telemetry/metrics.py
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from f28baa3 to 6140e16 Compare October 15, 2026 18:30
@litianningdatadog
litianningdatadog requested a review from a team as a code owner October 15, 2026 18:30
@litianningdatadog
litianningdatadog requested review from florentinl and removed request for a team October 15, 2026 18:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +745 to +749
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +399 to +402
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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread ddtrace/internal/telemetry/writer.py Outdated

@datadog-prod-us1-5 datadog-prod-us1-5 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bits Code Review: FAIL

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().

Open Bits AI session

🤖 Bits Code Review · Commit 6140e16

Comment thread ddtrace/internal/telemetry/writer.py Outdated
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from 6140e16 to 3d91ccf Compare October 15, 2026 18:55
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-3-2part-1-tracer-identity-refresh branch from 4467f33 to 1b08ad8 Compare October 15, 2026 18:55

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from 3d91ccf to db97dfc Compare October 15, 2026 20:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines 450 to +452
try:
self._worker.start()
except Exception:
except BaseException as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +502 to +505
self._worker = None
self.started = False
_unbind_metric_recorders(self)
self._notify_worker_changed(None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@litianningdatadog litianningdatadog Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is one of the known gaps, #20956 covers that.

gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Oct 15, 2026
…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>
Base automatically changed from tianning.li/3-3-2part-1-tracer-identity-refresh to main October 15, 2026 20:54

@datadog-prod-us1-5 datadog-prod-us1-5 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bits Code Review: PASS

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.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit db97dfc

@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from db97dfc to df094a9 Compare October 15, 2026 21:04
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>
@litianningdatadog
litianningdatadog force-pushed the tianning.li/3-4-2part-1-telemetry-identity-refresh branch from df094a9 to da7829a Compare October 15, 2026 21:44

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +380 to +383
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +406 to +410
# 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

aws-microvm Work related to AWS MicroVM onboarding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants