Skip to content

chore: wrapping context support for Python 3.15 - #17849

Open
P403n1x87 wants to merge 20 commits into
mainfrom
chore/315-wrapping-context
Open

chore: wrapping context support for Python 3.15#17849
P403n1x87 wants to merge 20 commits into
mainfrom
chore/315-wrapping-context

Conversation

@P403n1x87

@P403n1x87 P403n1x87 commented May 1, 2026

Copy link
Copy Markdown
Collaborator

prev: #19247 | next: #19253

Summary

Wrapping context + bytecode injection for 3.15.

Test plan

  • CI green on this branch
  • Stack merges cleanly into the next PR's base branch

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented May 1, 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/debugging/_function/store.py                                    @DataDog/debugger-python
ddtrace/internal/bytecode_injection/__init__.py                         @DataDog/apm-core-python
ddtrace/internal/monitoring.py                                          @DataDog/apm-core-python
ddtrace/internal/wrapping/__init__.py                                   @DataDog/apm-core-python
ddtrace/internal/wrapping/asyncs.py                                     @DataDog/apm-core-python
ddtrace/internal/wrapping/context.py                                    @DataDog/apm-core-python
ddtrace/internal/wrapping/generators.py                                 @DataDog/apm-core-python
mypy.ini                                                                @DataDog/python-guild @DataDog/apm-core-python
tests/internal/bytecode_injection/test_injection.py                     @DataDog/apm-core-python
tests/internal/test_monitoring.py                                       @DataDog/apm-core-python
tests/internal/test_wrapping.py                                         @DataDog/debugger-python @DataDog/apm-core-python
tests/wrapping/conftest.py                                              @DataDog/python-guild
tests/wrapping/test_unwrap.py                                           @DataDog/apm-core-python

@P403n1x87 P403n1x87 added the changelog/no-changelog A changelog entry is not required for this PR. label May 1, 2026
@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

🚦 9 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | build linux serverless: [amd64, cp315-cp315, v113741238-d2b8243-manylinux2014_x86_64, 1] — 🔧 Needs a code fix, caused by this PR

View in Datadog · View in GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [amd64, cp315-cp315, v126532274-233089d-musllinux_1_2_x86_64, 1] — 🔧 Needs a code fix, caused by this PR

View in Datadog · View in GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [arm64, cp315-cp315, v113741357-d2b8243-manylinux2014_aarch64, 1] — 🔧 Needs a code fix, caused by this PR

View in Datadog · View in GitLab

View all 9 failed jobs.

ℹ️ 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: e530750 | Docs | View more details | Give us feedback!

@pr-commenter

pr-commenter Bot commented May 1, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-20 10:47:35

Comparing candidate commit e530750 in PR branch chore/315-wrapping-context with baseline commit e155a58 in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 8 performance regressions! Performance is the same for 608 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 [+1.661µs; +1.799µs] or [+8.647%; +9.363%]

scenario:iastaspects-rstrip_aspect

  • 🟥 execution_time [+35.010µs; +44.529µs] or [+9.097%; +11.571%]

scenario:iastaspects-stringio_aspect

  • 🟥 execution_time [+640.823µs; +686.573µs] or [+19.643%; +21.046%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+103.335µs; +112.311µs] or [+24.713%; +26.860%]

scenario:iastaspectssplit-rsplit_aspect

  • 🟥 execution_time [+17.610µs; +22.122µs] or [+12.171%; +15.290%]

scenario:span-start

  • 🟥 execution_time [+1.443ms; +1.597ms] or [+9.677%; +10.708%]

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

  • 🟥 execution_time [+480.211ns; +528.799ns] or [+17.682%; +19.471%]

scenario:tracer-small

  • 🟥 execution_time [+29.975µs; +32.475µs] or [+8.994%; +9.744%]

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 [-661.669ns; +815.294ns] or [-6.019%; +7.417%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-35.055ns; +31.185ns] or [-5.735%; +5.102%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1766.893ns; +1562.773ns] or [-10.370%; +9.172%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1204.708ns; +1264.729ns] or [-9.279%; +9.741%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-333.497ns; +325.095ns] or [-9.055%; +8.827%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-262.270ns; +248.500ns] or [-8.962%; +8.492%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-76.150ns; +69.257ns] or [-6.650%; +6.048%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-3778.734ns; +4208.896ns] or [-9.301%; +10.360%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-757.373ns; +779.353ns] or [-9.227%; +9.495%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-52.528µs; +68.994µs] or [-8.211%; +10.785%]

@vlad-scherbich

vlad-scherbich commented May 19, 2026

Copy link
Copy Markdown
Contributor

@P403n1x87 I've taken a look at the PR, and it makes sense to me for the most part. For the parts that I don't have much experience with, I've asked Claude for thoughts. Here are its findings, hopefully they will be helpful:

Must be fixed before merging

1. Hot-path callbacks iterate a mutable dict without synchronization (monitoring.py)

The callbacks (_on_py_start, _on_py_return, etc.) do:

entries = _registry.get(code)
# ...
for e in entries.values():
...

Meanwhile register() and unregister() mutate that same inner dict (entries[id(handler)] = entry / existing.pop(...)) while holding _registry_lock -- but the callbacks don't acquire the lock. If a register call adds or removes an entry between two iterations of the for loop (GIL can switch between next calls on the dict view), you get RuntimeError: dictionary changed size during iteration.

Fix options: (a) snapshot with list(entries.values()) in callbacks (one small allocation per event), (b) swap to a copy-on-write scheme where register/unregister replace the entire inner dict atomically (the reference assignment is GIL-atomic, so the callback always sees a consistent snapshot), or (c) hold the lock in callbacks (worst for latency).

Option (b) is the best tradeoff: zero allocation on the hot path, and register/unregister are cold.

Nice to have

2. Tool ID allocation starts at 0, competing with debuggers (monitoring.py)

for tid in range(6):
    try:
        sys.monitoring.use_tool_id(tid, "ddtrace")
...

IDs 0-2 are conventionally reserved (debugger, coverage, profiler). Starting from 0 means ddtrace could claim the debugger slot if no debugger is attached yet, then a later debugpy or pdb attach would fail to register. Starting from 3 (or iterating range(5, -1, -1) to prefer higher IDs) would be more neighborly.

3. _ENTER_FRAME_DEPTH = 3 is fragile (context.py)

_ENTER_FRAME_DEPTH = 3 if sys.version_info >= (3, 15) else 1

This assumes a fixed call-stack depth from the monitored function through the monitoring dispatch to enter. If CPython changes the monitoring callback invocation depth, or if the multiplexer adds/removes a level, this silently produces the wrong frame. Consider walking the stack looking for the code object that matches self.wrapped.code rather than assuming a depth.

@P403n1x87

Copy link
Copy Markdown
Collaborator Author

Awesome, thanks!

register() and unregister() mutate that same inner dict (entries[id(handler)] = entry / existing.pop(...)) while holding _registry_lock

The reasoning here was that it would be unlikely to have mutation while callbacks are invoked, because these are generally installed on enablement (boot). However RC might violate this assumption, so it won't cost us much to be a bit defensive here.

IDs 0-2 are conventionally reserved (debugger, coverage, profiler). Starting from 0 means ddtrace could claim the debugger slot if no debugger is attached yet, then a later debugpy or pdb attach would fail to register. Starting from 3 (or iterating range(5, -1, -1) to prefer higher IDs) would be more neighborly.

We are a debugger as a matter of fact 🙁 but I guess it doesn't matter where we start with the ID so we can just comply.

3. _ENTER_FRAME_DEPTH = 3 is fragile (context.py)

Deliberate choice. This value should be fixed for each Python release, so the cost is at most 1 update every year. Still much better than updating a whole bunch of opcodes 🙂

@gyuheon0h

This comment was marked as resolved.

Comment thread ddtrace/internal/monitoring.py Outdated
Comment thread ddtrace/appsec/_iast/_taint_tracking/aspects/helpers.h
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Jul 7, 2026

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

@vlad-scherbich
vlad-scherbich changed the base branch from main to ci/build-base-venvs-oom July 9, 2026 20:35
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Jul 13, 2026
## Description

`build_base_venvs` is OOMKilled for cold-`ext_cache` Python versions. Currently this happens for `v3.15` only, which is new and has no warm cache. This was found while working on #17849.

**Root cause**
- It sets **no** `KUBERNETES_MEMORY_*` and does not disable the VPA, so the autoscaler tunes the limit down to ~5GB, based on the cheap **warm-cache** history.
- A cold version compiles everything at 12-way parallelism, which exceeds the approximated limit.

### Changes

Pin CPU/memory and disable the VPA on `build_base_venvs`, matching what `.build_base`, `test sdist` already use for the same build targets:

```yaml
KUBERNETES_CPU_REQUEST: '6'
KUBERNETES_MEMORY_REQUEST: '10Gi'
KUBERNETES_MEMORY_LIMIT: '10Gi'
DD_DISABLE_VPA: 'true'
```

## Test plan

* [Next PR](#17849) passes [dd-gitlab/build_base_venvs: [3.15]](https://gitlab.ddbuild.io/datadog/apm-reliability/dd-trace-py/builds/1846141002)

<img width="494" height="472" alt="Screenshot 2026-07-09 at 4 25 46 PM" src="https://github.com/user-attachments/assets/c622de21-a232-4f23-a085-728ad4461d25" />
 

## Additional Notes

* Warm builds are unaffected.
* The failing job logs also show the GitLab **runner S3 cache returning 403 AccessDenied**, so `ext_cache` restore fails on every run and forces cold compiles every time. That's a runner/`ddbuild` platform-side credential issue. Fixing this separately would restores warm-build speed.

Co-authored-by: vlad.scherbich <vlad.scherbich@datadoghq.com>
Base automatically changed from ci/build-base-venvs-oom to main July 13, 2026 20:51
@vlad-scherbich
vlad-scherbich changed the base branch from main to vlad/ci-auto-regen-requirements July 14, 2026 15:13
@vlad-scherbich
vlad-scherbich changed the base branch from vlad/ci-auto-regen-requirements to main July 14, 2026 15:26

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 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.

Datadog Autotest: FAIL

The new 3.15 monitoring callback captures the wrong frame: the configured depth stops at the dispatch callback rather than the wrapped function. This breaks WrappingContext.__frame__, so get_local() and frame-based products such as debugger snapshots and lazy module execution can read dispatcher locals or fail on Python 3.15.

Open Bits AI session

🤖 Datadog Autotest · Commit b95b04c · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

Comment thread ddtrace/internal/wrapping/context.py

@emmettbutler emmettbutler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'll wait to do a close review until CI is passing and conversations are resolved

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 13, 2026

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

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

Show existing violations (showing 5 of 252 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=135)
ddtrace.appsec._contrib.django -×-> ddtrace.trace  (product:appsec -> product:tracing, score=133)
ddtrace.profiling.collector.stack -×-> ddtrace.trace  (product:profiling -> product:tracing, score=133)
ddtrace.debugging._debugger -×-> ddtrace.trace  (product:debugging -> product:tracing, score=133)
ddtrace.llmobs._integrations.openai -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=133)

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

Comment thread ddtrace/internal/wrapping/context.py
Comment thread ddtrace/internal/monitoring.py Outdated
Comment thread ddtrace/internal/monitoring.py Outdated
Comment thread ddtrace/internal/monitoring.py
Comment thread ddtrace/debugging/_function/store.py Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/no-changelog A changelog entry is not required for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants