Skip to content

fix(asm): stop reporting pass-through instrumentation frames as Datadog crashes - #20072

Draft
avara1986 wants to merge 1 commit into
mainfrom
alberto.vara/APPSEC-69877-crashtracker-frame-filter
Draft

fix(asm): stop reporting pass-through instrumentation frames as Datadog crashes#20072
avara1986 wants to merge 1 commit into
mainfrom
alberto.vara/APPSEC-69877-crashtracker-frame-filter

Conversation

@avara1986

@avara1986 avara1986 commented Sep 4, 2026

Copy link
Copy Markdown
Member

Summary

Track A of APPSEC-69877. Mmigrating the pure-Python RASP hooks to WrappingContext, APPSEC-69878 is #19989 and is not a substitute: builtins.open, os.system, os.fork, builtins.eval, stripe and the IAST hashlib sinks are C callables with no __code__, so they cannot be bytecode-wrapped and keep leaving frames.

Every unhandled exception is shipped to crash tracking with its full traceback, and the intake tags the report crash_datadog:true as soon as a ddtrace path appears among the frames. Because the RASP hooks wrap those targets with wrapt, our wrapper frame sits in the traceback of ordinary application errors and we get blamed for the customer's bug (dashboard). TelemetryWriter._format_stack_trace leaks the same frames into telemetry error logs.

This filters them at both reporting boundaries. The traceback the application itself sees is unchanged — only our own reports lose the frame.

Design

Registered, not inferred. A frame is dropped only when its code object was registered as a wrapper that exists to forward a call, so an exception that genuinely originates in the library keeps its attribution. Registration is automatic: try_wrap_function_wrapper is the single funnel for every appsec wrapt wrapper, so one call there covers filesystem, stripe and IAST.

Why not the positional rule

The rule that suggests itself — "if the deepest frame is ours, we raised it; otherwise drop our frames" — fails on the most common case:

builtins.open on a missing file:  FileNotFoundError
  ...
  DDTRACE patch.py:43 wrapped_builtin_open  |  return original(*args, **kwargs)
  deepest frame is ddtrace: True

builtins.open is a C callable and owns no frame, so the wrapper is the deepest frame even though it only forwarded. Dropping every ddtrace frame instead would discard genuine faults, including background-thread crashes — precisely what crash tracking exists for.

Resolving the deepest-frame case

A registered wrapper that is the deepest frame is resolved by the instruction it stopped on. A Python callee would own the deepest frame itself, so a frame that is both deepest and stopped on a CALL was forwarding to a C callable. Measured identical on 3.9, 3.11, 3.12 and 3.14:

case out_of_call result
pass-through to a C callable True dropped
our own explicit raise False kept
our own implicit raise (KeyError) False kept
our own bad call to a C callable True dropped

The last row is the known gap: a bug in a wrapper that raises through a C call is misattributed. It is narrower than the alternatives, which lose rows 2 and 3 as well.

Risks

  • Coverage is appsec-only. Contrib integrations still leave frames. Registering at the trace_utils and ddtrace.internal.wrapping funnels is a one-line follow-up each, left out here because it changes behaviour for every integration and wants its own review.
  • Reviewers outside AppSec. ddtrace/internal/telemetry/writer.py and ddtrace/internal/core/crashtracking.py are not appsec-owned.
  • A sys.tracebacklimit that truncates extract_tb but not the raw walk desynchronises the two, so the traceback is reported unfiltered rather than mismatched. Covered by a test.

Testing

tests/internal/test_instrumentation_frames.py (8 tests) covers: unregistered wrapper kept; registered wrapper dropped for a Python callee and for a C callee; registered wrapper kept when it raised explicitly or implicitly; empty registry is a no-op; an all-passthrough traceback is reported rather than emptied; a truncated traceback is reported unfiltered.

tests/appsec/appsec/test_filesystem.py adds the end-to-end assertion for builtins.open and pathlib.Path.open: the application's traceback still contains the wrapper, the reported frames do not, and the customer's own frame survives.

Mutation-checked — no-oping the registration fails the appsec tests.

internal 833 passed; telemetry, appsec::appsec and crashtracker green on py3.13. scripts/lint fmt, typing, spelling and suitespec-check clean.

Checklist

  • PR author has checked that all the criteria below are met
  • The PR description includes an overview of the change
  • The PR description articulates the motivation for the change
  • The change includes tests OR the PR description describes a testing strategy
  • The PR description notes risks associated with the change, if any
  • Newly-added code is easy to change
  • The change follows the library release note guidelines
  • The change includes or references documentation updates if necessary
  • Backport labels are set (if applicable)

Reviewer Checklist

  • Reviewer has checked that all the criteria below are met
  • Title is accurate
  • All changes are related to the pull request's stated goal
  • Avoids breaking API changes
  • Testing strategy adequately addresses listed risks
  • Newly-added code is easy to change
  • Release note makes sense to a user of the library
  • If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment
  • Backport labels are set in a manner that is consistent with the release branch maintenance policy

🤖 Generated with Claude Code

…og crashes [APPSEC-69877]

Every unhandled exception is shipped to crash tracking with its full traceback,
and the intake tags the report crash_datadog:true as soon as a ddtrace path
appears among the frames. The RASP hooks wrap builtins.open, os.system,
pathlib.Path.open, stripe and the IAST sinks with wrapt, so our wrapper frame
sits in the traceback of ordinary application errors and we get blamed for the
customer's bug. TelemetryWriter._format_stack_trace leaks the same frames into
telemetry error logs.

Filter them where they are reported. What the application itself sees is
unchanged; only our own reports lose the frame.

Registered, not inferred. A frame is dropped only when its code object was
registered as a wrapper that exists to forward a call, so an exception that
genuinely originates in the library keeps its attribution. Registration is
automatic: try_wrap_function_wrapper is the single funnel for every appsec
wrapt wrapper, so one call there covers filesystem, stripe and IAST.

The positional rule that suggests itself - "if the deepest frame is ours, we
raised it" - does not work, and the case it fails on is the most common one:

  builtins.open on a missing file -> FileNotFoundError
    DDTRACE patch.py:43 wrapped_builtin_open  |  return original(*args, **kwargs)
    deepest frame is ddtrace: True

builtins.open is a C callable and owns no frame, so the wrapper is the deepest
frame even though it only forwarded. Dropping every ddtrace frame instead would
discard genuine faults, including background-thread crashes, which is precisely
what crash tracking is for.

So a registered wrapper that is the deepest frame is resolved by looking at the
instruction it stopped on. A Python callee would own the deepest frame itself,
so a frame that is both deepest and stopped on a CALL was forwarding to a C
callable. Measured identical on 3.9, 3.11, 3.12 and 3.14:

  pass-through to a C callable        out_of_call=True   dropped
  our own explicit raise              out_of_call=False  kept
  our own implicit raise (KeyError)   out_of_call=False  kept
  our own bad call to a C callable    out_of_call=True   dropped

The last row is the known gap: a bug in a wrapper that raises through a C call
is misattributed. It is narrower than the alternatives, which lose rows 2 and 3
as well.

Coverage is appsec-only for now. Contrib integrations still leave frames;
registering at the trace_utils and internal wrapping funnels is a follow-up,
since it changes behaviour for every integration.

internal 833 passed; telemetry, appsec::appsec and crashtracker green on
py3.13. No-oping the registration fails the appsec tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cit-pr-commenter-54b7da

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/appsec/_patch_utils.py                                          @DataDog/asm-python
ddtrace/internal/_instrumentation_frames.py                             @DataDog/apm-core-python
ddtrace/internal/core/crashtracking.py                                  @DataDog/profiling-python @DataDog/apm-core-python
ddtrace/internal/telemetry/writer.py                                    @DataDog/apm-python
releasenotes/notes/fix-crash-attribution-instrumentation-frames-8534bdc60fd524e7.yaml  @DataDog/apm-python
tests/appsec/appsec/test_filesystem.py                                  @DataDog/asm-python
tests/internal/test_instrumentation_frames.py                           @DataDog/apm-core-python

@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

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

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

@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

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

Show existing violations (showing 5 of 230 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=134)
ddtrace.internal.test_visibility.api -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=132)
ddtrace.profiling.collector.stack -×-> ddtrace.trace  (product:profiling -> product:tracing, score=132)
ddtrace.llmobs._integrations.claude_agent_sdk -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=132)
ddtrace.llmobs._integrations.vllm -×-> 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

@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Pipelines  Tests

⚠️ Warnings

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 | package-oci: [linux, arm64] — 🔄 Retry may pass, looks flaky

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-py | download_dependency_wheels: [3.15.0rc1, 3.15]

View more details · View in GitLab

❄️ 3 New flaky tests detected

test_an_all_passthrough_traceback_is_reported_rather_than_emptied[py3.11] from test_instrumentation_frames.py
assert [&#39;wrapped_builtin_open&#39;] == [&#39;wrapper_forwarding&#39;]
 &#43;  where [&#39;wrapped_builtin_open&#39;] = _names([&lt;FrameSummary file /go/src/github.com/DataDog/apm-reliability/dd-trace-py/ddtrace/appsec/_contrib/filesystem/patch.py, line 43 in wrapped_builtin_open&gt;])
 &#43;    where [&lt;FrameSummary file /go/src/github.com/DataDog/apm-reliability/dd-trace-py/ddtrace/appsec/_contrib/filesystem/patch.py, line 43 in wrapped_builtin_open&gt;] = &lt;function extract_reportable_frames at 0x77450a411940&gt;(&lt;traceback object at 0x774508121880&gt;)
 &#43;      where &lt;function extract_reportable_frames at 0x77450a411940&gt; = frames.extract_reportable_frames
test_an_all_passthrough_traceback_is_reported_rather_than_emptied[py3.13] from test_instrumentation_frames.py
assert [&#39;wrapped_builtin_open&#39;] == [&#39;wrapper_forwarding&#39;]
  
  At index 0 diff: &#39;wrapped_builtin_open&#39; != &#39;wrapper_forwarding&#39;
  
  Full diff:
    [
  -     &#39;wrapper_forwarding&#39;,
  &#43;     &#39;wrapped_builtin_open&#39;,
    ]

View in Flaky Test Management

ℹ️ Info

No other issues found (see more)

🧪 All tests passed

Useful? React with 👍 / 👎

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

@pr-commenter

pr-commenter Bot commented Sep 4, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-09-04 11:57:10

Comparing candidate commit 2ac9213 in PR branch alberto.vara/APPSEC-69877-crashtracker-frame-filter with baseline commit 5e53ff0 in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 9 performance regressions! Performance is the same for 572 metrics, 10 unstable metrics, 3 known flaky benchmarks, 15 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:httppropagationinject-ids_only

  • 🟥 execution_time [+1.623µs; +1.813µs] or [+9.331%; +10.424%]

scenario:iastaspects-join_aspect

  • 🟥 execution_time [+41.881µs; +46.913µs] or [+19.066%; +21.357%]

scenario:iastaspects-title_aspect

  • 🟥 execution_time [+56.518µs; +62.696µs] or [+20.505%; +22.746%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+123.819µs; +129.661µs] or [+30.085%; +31.505%]

scenario:iastaspectssplit-rsplit_aspect

  • 🟥 execution_time [+12.631µs; +16.553µs] or [+8.790%; +11.519%]

scenario:otelspan-start

  • 🟥 execution_time [+2.551ms; +3.594ms] or [+8.138%; +11.466%]

scenario:samplingrules-high_match

  • 🟥 execution_time [+20.743µs; +23.253µs] or [+13.690%; +15.346%]

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

  • 🟥 execution_time [+419.717ns; +445.231ns] or [+15.138%; +16.058%]

scenario:tracer-small

  • 🟥 execution_time [+47.144µs; +49.798µs] or [+14.608%; +15.431%]

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 [-799.506ns; +685.987ns] or [-7.145%; +6.131%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-38.190ns; +27.867ns] or [-6.195%; +4.521%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1642.082ns; +1648.594ns] or [-9.642%; +9.681%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1085.447ns; +1343.040ns] or [-8.387%; +10.377%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-331.208ns; +314.994ns] or [-8.966%; +8.527%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-255.167ns; +244.724ns] or [-8.620%; +8.267%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-100.143ns; +49.666ns] or [-8.619%; +4.274%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-3572.541ns; +4439.433ns] or [-8.778%; +10.908%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-601.085ns; +965.040ns] or [-7.518%; +12.071%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-49.366µs; +62.250µs] or [-8.513%; +10.735%]

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:iastaspects-casefold_noaspect

  • 🟥 execution_time [+33.262µs; +39.266µs] or [+13.112%; +15.479%]

scenario:iastaspects-ljust_noaspect

  • 🟥 execution_time [+46.268µs; +53.010µs] or [+16.015%; +18.349%]

scenario:span-start

  • 🟥 execution_time [+1.746ms; +1.909ms] or [+12.372%; +13.528%]

Known flaky benchmarks without significant changes:

  • scenario:errortrackingflasksqli-baseline
  • scenario:flasksimple-iast-get
  • scenario:iastaspects-casefold_aspect
  • scenario:iastaspects-index_aspect
  • scenario:iastaspects-lower_aspect
  • scenario:iastaspects-replace_aspect
  • scenario:iastaspects-swapcase_aspect
  • scenario:iastaspects-title_noaspect
  • scenario:iastaspects-translate_aspect
  • scenario:iastaspects-translate_noaspect
  • scenario:iastaspects-upper_noaspect
  • scenario:packagespackageforrootmodulemapping-cache_off
  • scenario:packagespackageforrootmodulemapping-cache_on
  • scenario:sethttpmeta-all-enabled
  • scenario:telemetryaddmetric-record-100-metrics

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