Skip to content

fix(crashtracking): isolate receiver from bootstrap instrumentation - #19735

Closed
taegyunkim wants to merge 2 commits into
mainfrom
taegyunkim/fix-crashtracker-receiver-ssi-recursion
Closed

taegyunkim wants to merge 2 commits into
mainfrom
taegyunkim/fix-crashtracker-receiver-ssi-recursion

Conversation

@taegyunkim

@taegyunkim taegyunkim commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Description

Prevents the Python crashtracker receiver from auto-instrumenting itself when the parent process was started through Single Step Instrumentation or ddtrace-run.

Receiver construction now removes only the exact <ddtrace package>/bootstrap entry derived from the receiver script path while preserving the rest of PYTHONPATH, including the selected ddtrace package location. LD_LIBRARY_PATH and DYLD_LIBRARY_PATH inheritance are unchanged.

The regression captures the generated receiver environment and starts its configured Python interpreter. It verifies both that ddtrace.internal.native._native remains importable and that ddtrace.bootstrap.preload is not loaded.

This fixes receiver self-instrumentation, not the separate libdatadog receiver segfault in remote libunwind symbol resolution.

Testing

  • Regression-only commit bfe6e1c5:
    • Local: failed as expected because ddtrace.bootstrap.preload was present in sys.modules.
    • Remote: dd-gitlab/core/crashtracker 1/2 failed as required.
  • Fix commit baeb209e:
    • Local: scripts/run-tests --venv 1ef26c5 -- -s -- tests/crashtracker/test_crashtracker.py -k receiver_pythonpath_isolation -vv passed.
    • Local: scripts/run-tests --venv 1ef26c5 -- -s -- tests/crashtracker/test_crashtracker.py -vv passed all 28 tests on Python 3.13.
    • Local: scripts/lint typing -- ddtrace/internal/core/crashtracking.py and scripts/lint checks passed.
    • Remote: all four dd-gitlab/core/crashtracker shards passed.

Risks

Do not merge this PR before the separate libdatadog receiver crash is fixed, or before equivalent receiver self-failure telemetry is added. The nested crashtracker currently makes that receiver segfault observable. Removing self-instrumentation alone would make the existing crash-report loss silent.

The code change is narrowly scoped to exact-match filtering of the receiver's bootstrap directory from PYTHONPATH.

Additional Notes

The same bootstrap propagation occurs with SSI and ddtrace-run, so the fix is not SSI-specific.

@taegyunkim taegyunkim added the changelog/no-changelog A changelog entry is not required for this PR. label Aug 17, 2026
@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🔄 Datadog auto-retried 2 jobs - 2 passed on retry View in Datadog

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: baeb209 | Docs | Datadog PR Page | Give us feedback!

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 17, 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/core/crashtracking.py                                  @DataDog/profiling-python @DataDog/apm-core-python
releasenotes/notes/fix-crashtracker-receiver-recursion-7cf2518c488864c1.yaml  @DataDog/apm-python
tests/crashtracker/test_crashtracker.py                                 @DataDog/profiling-python

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

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

Show existing violations (showing 5 of 254 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=134)
ddtrace.llmobs._integrations.bedrock_agents -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)
ddtrace.internal.ci_visibility.git_client -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=132)
ddtrace.llmobs._integrations.mcp -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)
ddtrace.llmobs._integrations.crewai -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)

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

cit-pr-commenter-54b7da Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

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

ddtrace.contrib.internal.django.patch -> ddtrace.contrib.internal.django.response -> ddtrace.contrib.internal.django.patch
ddtrace.contrib.internal.pytorch._distributed -> ddtrace.contrib.internal.pytorch._rank_root -> ddtrace.contrib.internal.pytorch._distributed
ddtrace.llmobs -> ddtrace.llmobs._evaluators -> ddtrace.llmobs._evaluators.format -> ddtrace.llmobs._experiment -> ddtrace.llmobs
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
ddtrace.appsec._asm_request_context -> ddtrace.appsec._iast._iast_request_context_base -> ddtrace.appsec._iast._iast_env -> ddtrace.appsec._iast.reporter -> ddtrace.appsec._exploit_prevention.stack_traces -> ddtrace.appsec._asm_request_context

@taegyunkim taegyunkim removed the changelog/no-changelog A changelog entry is not required for this PR. label Aug 17, 2026
@taegyunkim taegyunkim changed the title test(crashtracking): reproduce receiver SSI recursion fix(crashtracking): isolate receiver from bootstrap instrumentation Aug 17, 2026
@pr-commenter

pr-commenter Bot commented Aug 17, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-17 20:50:27

Comparing candidate commit baeb209 in PR branch taegyunkim/fix-crashtracker-receiver-ssi-recursion with baseline commit 3b40799 in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 8 performance regressions! Performance is the same for 610 metrics, 10 unstable metrics.

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:httppropagationinject-ids_only

  • 🟥 execution_time [+2.618µs; +2.754µs] or [+12.488%; +13.137%]

scenario:iastaspects-repr_aspect

  • 🟥 execution_time [+66.638µs; +75.204µs] or [+17.686%; +19.959%]

scenario:iastaspects-upper_aspect

  • 🟥 execution_time [+42.769µs; +47.757µs] or [+17.562%; +19.610%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+109.451µs; +117.143µs] or [+25.633%; +27.435%]

scenario:iastaspectssplit-rsplit_aspect

  • 🟥 execution_time [+10.394µs; +15.462µs] or [+7.212%; +10.729%]

scenario:span-start

  • 🟥 execution_time [+1.281ms; +1.445ms] or [+8.472%; +9.559%]

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

  • 🟥 execution_time [+460.212ns; +499.249ns] or [+17.418%; +18.895%]

scenario:tracer-small

  • 🟥 execution_time [+32.980µs; +34.996µs] or [+9.791%; +10.389%]

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 [-686.295ns; +789.361ns] or [-6.242%; +7.180%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-34.944ns; +30.787ns] or [-5.675%; +5.000%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1680.826ns; +1595.743ns] or [-9.898%; +9.397%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1298.947ns; +1153.529ns] or [-10.042%; +8.918%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-331.197ns; +320.403ns] or [-9.019%; +8.725%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-231.146ns; +286.011ns] or [-7.909%; +9.787%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-79.972ns; +67.211ns] or [-6.888%; +5.789%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-3927.647ns; +4108.463ns] or [-9.630%; +10.074%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-718.348ns; +832.796ns] or [-8.846%; +10.256%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-59.203µs; +61.405µs] or [-9.274%; +9.619%]

gyuheon0h pushed a commit to DataDog/libdatadog that referenced this pull request Aug 19, 2026
…he receiver (#2361)

# Summary

Keep libunwind for remote stack walking, stop using it for redundant and
crash-prone ELF symbol lookup, and let blazesym do the one symbolization
pass with an ELF fallback when the application is already gone.

# Why?

The crashtracker uses a separate receiver process because the crashing
application's signal handler cannot safely parse binaries, collect other
thread stacks, or upload a report itself.

When all-thread collection is enabled, the receiver ptrace-attaches each
application thread and uses libunwind's remote API to walk its stack.
Stack walking and symbolization are separate operations:

```text
unw_get_reg_remote / unw_step_remote
    -> collect instruction and stack pointers

unw_get_proc_name_remote
    -> search the target's ELF symbol tables for a function name
```

The second operation ran once per frame per thread inside
`unwind_remote_thread`. For affected processes, libunwind segfaulted
while searching an ELF symbol table:

```text
unw_get_proc_name_remote
  -> _Ux86_64_get_proc_name
    -> _Ux86_64_get_proc_name_by_ip
      -> _Uelf64_get_proc_name
        -> _Uelf64_get_proc_name_in_image
          -> _Uelf64_lookup_symbol_closeness  # receiver crashes here
```

This destroys the original crash report. Thread collection runs before
`builder.build()` and `async_upload_to_endpoint`, so a receiver that
crashes here never uploads the application's report.

The risky lookup was also redundant on successful paths.
`CrashInfo::enrich_callstacks` subsequently asks blazesym to symbolize
the same thread frames and overwrites the libunwind function name. If
blazesym failed, the libunwind name previously remained as an accidental
fallback, but retaining that fallback meant risking the entire report.
An uploaded report with unresolved addresses is preferable to losing all
crash data.

# How did we detect this?

The Python receiver was unintentionally auto-instrumenting itself in
affected SSI and `ddtrace-run` environments. The parent application's
`PYTHONPATH` included `ddtrace/bootstrap`, and that path was forwarded
to the receiver. On startup, the receiver ran the full ddtrace preload
and started its own nested crashtracker.

That nested crashtracker did not cause the libunwind fault, but it made
the fault observable:

```text
Application
  -> Receiver R1 processes the application crash
      -> R1 crashes in _Uelf64_lookup_symbol_closeness
          -> R1's nested crashtracker starts Receiver R2
              -> R2 uploads R1's crash report
```

The resulting reports identify `_dd_crashtracker_receiver` as the
crashed process and contain instrumentation threads such as
`TelemetryWriter`, `RemoteConfigPol`, `RemoteConfigSub`, and
`SignalUploader`.

In a 120-hour dd-trace-py 4.13.x window we found **5,492 receiver
crashes**. **5,431 (98.9%)** contain the libunwind `get_proc_name` /
`_Uelf64_lookup_symbol_closeness` stack. The worst-affected customer
produced 5,389 receiver crashes against only 47 delivered application
crash reports.

[Representative receiver crash and matching events in Datadog
Logs](https://app.datadoghq.com/logs?query=service%3Ainstrumentation-telemetry-data%2A%20%28%40tags.severity%3Acrash%20OR%20severity%3Acrash%20OR%20signum%3A%2A%20OR%20%40error.is_crash%3Atrue%29%20%40lib_language%3A%2A%20%40tracer_version%3A%2A%20-%40tags.crash_runtime%3Atrue%20%40org_id%3A%2A%20%40error.stack.frames.function%3A_Ux86_64_get_proc_name&agg_m=count&agg_m_source=base&agg_t=count&clustering_pattern_field_path=message&cols=host%2Cservice&event=AwAAAaAN2Q4H5mHhTgAAABhBYUFOMlE0SEFBQW83Ukt0YlUxRkxBQVIAAAAkMDFhMDBkZGYtOTZkNC00YWU3LWFhN2UtMzNhMjYwYzQxZTIxAAIv6g&fromUser=true&messageDisplay=inline&refresh_mode=paused&storage=hot&stream_sort=time%2Cdesc&viz=stream&from_ts=1786334400000&to_ts=1786939199999&live=false)

Without the receiver's accidental self-instrumentation, the libunwind
crash and application report loss would still occur, but the receiver
death would be largely silent.

[dd-trace-py #19735](DataDog/dd-trace-py#19735)
fixes that separate auto-instrumentation bug by removing the exact
`ddtrace/bootstrap` directory from the receiver's inherited `PYTHONPATH`
while preserving the injected ddtrace package path. It prevents the
receiver from starting tracing, profiling, and a nested crashtracker.
That PR intentionally remains draft until this libdatadog fix lands,
because merging it first would remove the telemetry that exposes these
receiver deaths without preventing the deaths or recovering the lost
application reports.

# How does this PR fix the problem?

1. **Keep remote stack walking.** The receiver still uses
`unw_init_remote`, `unw_get_reg_remote`, and `unw_step_remote` to
collect every thread's instruction and stack pointers.
2. **Remove receiver-side libunwind symbol lookup.**
`unw_get_proc_name_remote` is no longer called, so the receiver does not
enter the faulting ELF symbol-table search.
3. **Use blazesym once.** `CrashInfo::enrich_callstacks` remains
responsible for converting addresses into function names, files, and
line numbers.
4. **Handle an exited target process.** Process-based blazesym
symbolization requires `/proc/<pid>`. The sidecar receiver can outlive
the application, so this PR falls back to the ELF `path` and virtual
`relative_address` already recorded by `normalize_ip`. This needs no
live process.
5. **Degrade instead of losing the report.** If both blazesym paths
fail, the frame retains its IP, SP, and any normalized build
ID/path/relative address; the symbolization error is added to
`log_messages`; and the receiver still uploads the report.

# How did we test this?

A standalone reproducer and fundamental ELF parser hardening are
available in [DataDog/libdatadog-libunwind
#14](DataDog/libdatadog-libunwind#14), with the
underlying fork change in [DataDog/libunwind
#3](DataDog/libunwind#3). The reproducer
ptrace-stops a helper in a valid shared library, corrupts its on-disk
GNU hash metadata after loading, and calls `unw_get_proc_name_remote()`
on that frame. Before the parser fix it terminates with SIGSEGV in the
same call chain observed in telemetry; after the fix it returns
`-UNW_ENOINFO`.

```bash
cargo run -p bin_tests --bin prebuild
cargo test -p bin_tests --test crashtracker_bin_test
cargo test -p libdd-crashtracker --features generate-unit-test-files --lib
cargo clippy -p libdd-crashtracker --all-targets
```

Local Linux x86_64 results:

- **45/45 crashtracker integration tests passed.** This includes the
three multi-thread collection tests that assert `worker_fn_0` and
`worker_fn_1` are present in `error.threads[].stack.frames[].function`,
proving blazesym still names remotely collected thread frames without
`unw_get_proc_name_remote`.
- **161 unit tests passed, 1 ignored.**
- `cargo clippy` passed.
- Added `test_symbolization_after_process_exit`. It normalizes a frame
while the process is available, then symbolizes against a nonexistent
process and verifies that the recorded ELF path and virtual offset
recover `my_function`.
- Disabling the ELF fallback makes the new unit test fail and also makes
`test_crash_tracking_sidecar_multi_thread_collection` fail with `failed
to open proc maps file /proc/<pid>/maps`, confirming that the fallback
covers a real path rather than an artificial unit-test condition.
@taegyunkim

Copy link
Copy Markdown
Contributor Author

Closing in favor of #20084

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant