Skip to content

Commit 85133ea

Browse files
[mq] [skip ddci] working branch - merge 7a432ec on top of main at 3a825cc
{"baseBranch":"main","baseCommit":"3a825ccafb6026572c5132fd3a6058544f1e205d","createdAt":"2026-08-13T13:33:12.423411Z","headSha":"7a432ec3cf262476295f7296cc79a56b392e687f","id":"9d476dfb-5b9f-4da4-a987-d5a01bb7fd38","priority":"200","pullRequestNumber":"19431","queuedAt":"2026-08-13T13:33:12.422283Z","status":"STATUS_QUEUED"}
2 parents 8a63204 + 7a432ec commit 85133ea

8 files changed

Lines changed: 386 additions & 11 deletions

File tree

ddtrace/internal/native/_native.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@ def store_metadata(data: PyTracerMetadata) -> PyAnonymousFileHandle:
174174
"""
175175
...
176176

177+
if sys.implementation.name == "cpython" and sys.version_info >= (3, 14):
178+
def register_context_watcher() -> bool:
179+
"""Register the Python context watcher if a watcher slot is available."""
180+
...
181+
def is_context_watcher_registered() -> bool:
182+
"""Return whether the Python context watcher is registered."""
183+
...
184+
177185
if sys.platform == "linux":
178186
def update_otel_thread_context(span: SpanData, local_root: Optional[SpanData], trace_flags: int) -> None:
179187
"""

ddtrace/internal/opentelemetry/thread_context.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,43 @@ def context_provider(self) -> BaseContextProvider: ...
1717

1818

1919
_ContextActivationListener = Callable[[BaseContextProvider, Optional[Union[Context, Span]]], None]
20+
_ContextSwitchListener = Callable[[], None]
21+
_ThreadContextListeners = tuple[_ContextActivationListener, _ContextSwitchListener]
2022

2123

2224
if sys.platform == "linux":
2325
from ddtrace.internal.native._native import detach_otel_thread_context
2426
from ddtrace.internal.native._native import update_otel_thread_context
2527

26-
def register_otel_thread_context_listener(tracer: TracerProtocol) -> Optional[_ContextActivationListener]:
28+
def register_otel_thread_context_listener(tracer: TracerProtocol) -> Optional[_ThreadContextListeners]:
2729
if not config._otel_thread_context_enabled:
2830
return None
2931

30-
def _sync_otel_thread_context(provider: BaseContextProvider, ctx: Optional[Union[Context, Span]]) -> None:
31-
if provider is not tracer.context_provider:
32-
return
33-
32+
def _sync_otel_thread_context(ctx: Optional[Union[Context, Span]]) -> None:
3433
if type(ctx) is Span:
3534
sampling_priority = ctx._local_root.context.sampling_priority
3635
trace_flags = 1 if sampling_priority is not None and sampling_priority > 0 else 0
3736
update_otel_thread_context(ctx, ctx._local_root_value, trace_flags)
3837
else:
3938
detach_otel_thread_context()
4039

41-
core.on("ddtrace.context_provider.activate", _sync_otel_thread_context)
42-
return _sync_otel_thread_context
40+
def _sync_active_otel_thread_context() -> None:
41+
_sync_otel_thread_context(tracer.context_provider.active())
42+
43+
def _on_context_provider_activate(provider: BaseContextProvider, ctx: Optional[Union[Context, Span]]) -> None:
44+
if provider is tracer.context_provider:
45+
_sync_otel_thread_context(ctx)
46+
47+
core.on("ddtrace.context_provider.activate", _on_context_provider_activate)
48+
core.on("python.context.switch", _sync_active_otel_thread_context)
49+
50+
if sys.implementation.name == "cpython" and sys.version_info >= (3, 14):
51+
from ddtrace.internal.native._native import register_context_watcher
52+
53+
register_context_watcher()
54+
return _on_context_provider_activate, _sync_active_otel_thread_context
4355

4456
else:
4557

46-
def register_otel_thread_context_listener(tracer: TracerProtocol) -> Optional[_ContextActivationListener]:
58+
def register_otel_thread_context_listener(tracer: TracerProtocol) -> Optional[_ThreadContextListeners]:
4759
return None
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
features:
3+
- |
4+
tracing: Adds Python 3.14+ support for keeping OpenTelemetry thread-context records synchronized across asynchronous context switches.
5+
This behavior is enabled by default and can be disabled by setting ``DD_TRACE_OTEL_CTX_ENABLED`` to ``false``.

src/native/context_watcher.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
use pyo3::ffi;
2+
use pyo3::prelude::*;
3+
use std::ffi::{c_int, c_uint};
4+
use std::matches;
5+
use std::panic::{catch_unwind, AssertUnwindSafe};
6+
use std::sync::OnceLock;
7+
8+
const CONTEXT_SWITCH_EVENT: &str = "python.context.switch";
9+
10+
type PyContextEvent = c_uint;
11+
const PY_CONTEXT_SWITCHED: PyContextEvent = 1;
12+
type PyContextWatchCallback =
13+
unsafe extern "C" fn(event: PyContextEvent, object: *mut ffi::PyObject) -> c_int;
14+
15+
unsafe extern "C" {
16+
fn PyContext_AddWatcher(callback: PyContextWatchCallback) -> c_int;
17+
}
18+
19+
static WATCHER_ID: OnceLock<Option<c_int>> = OnceLock::new();
20+
21+
#[pyfunction]
22+
pub fn register_context_watcher(py: Python<'_>) -> bool {
23+
WATCHER_ID
24+
.get_or_init(|| {
25+
// SAFETY: This module is only compiled for CPython 3.14+ with the
26+
// GIL enabled, and the callback signature matches
27+
// PyContext_WatchCallback from cpython/context.h.
28+
let watcher_id = unsafe { PyContext_AddWatcher(context_watcher) };
29+
if watcher_id == -1 {
30+
// Context-switch publication is optional. If no watcher slot
31+
// is available, clear the C-API error and leave it disabled.
32+
drop(PyErr::fetch(py));
33+
None
34+
} else {
35+
Some(watcher_id)
36+
}
37+
})
38+
.is_some()
39+
}
40+
41+
#[pyfunction]
42+
pub fn is_context_watcher_registered() -> bool {
43+
matches!(WATCHER_ID.get(), Some(Some(_)))
44+
}
45+
46+
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
47+
m.add_function(wrap_pyfunction!(register_context_watcher, m)?)?;
48+
m.add_function(wrap_pyfunction!(is_context_watcher_registered, m)?)
49+
}
50+
51+
unsafe extern "C" fn context_watcher(event: PyContextEvent, object: *mut ffi::PyObject) -> c_int {
52+
// CPython may invoke watcher callbacks with an exception already set. Clear
53+
// it temporarily so listeners can use regular Python APIs, then restore it;
54+
// losing it makes Context.run raise SystemError instead of the original error.
55+
let pending_exception = unsafe { ffi::PyErr_GetRaisedException() };
56+
let callback_result = match catch_unwind(AssertUnwindSafe(|| {
57+
// CPython invokes context watchers on an attached thread, but entering
58+
// through the C API bypasses PyO3's attachment bookkeeping.
59+
Python::attach(|py| {
60+
if event == PY_CONTEXT_SWITCHED {
61+
// Listeners must not enter another Context: CPython context watchers
62+
// are reentrant. The OTel listener does not enter a Context.
63+
if let Err(error) =
64+
crate::event_hub::dispatch(py, CONTEXT_SWITCH_EVENT, None, false)
65+
{
66+
error.restore(py);
67+
return -1;
68+
}
69+
}
70+
71+
0
72+
})
73+
})) {
74+
Ok(result) => result,
75+
Err(_) => {
76+
// Keep panics from crossing the C boundary even if attaching itself
77+
// fails before a Python token is available.
78+
unsafe {
79+
ffi::PyErr_SetString(
80+
ffi::PyExc_RuntimeError,
81+
c"panic in Python context watcher".as_ptr(),
82+
)
83+
};
84+
-1
85+
}
86+
};
87+
88+
if pending_exception.is_null() {
89+
return callback_result;
90+
}
91+
92+
// A new callback error must not replace the exception which was pending on
93+
// entry. Report it as unraisable before restoring the original exception.
94+
unsafe {
95+
if callback_result == -1 {
96+
ffi::PyErr_WriteUnraisable(object);
97+
}
98+
ffi::PyErr_Clear();
99+
ffi::PyErr_SetRaisedException(pending_exception);
100+
}
101+
102+
0
103+
}

src/native/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ mod crashtracker;
44
pub use datadog_profiling_ffi::*;
55
mod config;
66
mod context_provider;
7+
#[cfg(all(Py_3_14, not(any(PyPy, GraalPy))))]
8+
mod context_watcher;
79
mod contextvar;
810
mod data_pipeline;
911
#[cfg(feature = "stats")]
@@ -102,5 +104,8 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
102104
// Add tracer_flare submodule
103105
m.add_wrapped(pyo3::wrap_pymodule!(tracer_flare::native_flare))?;
104106

107+
#[cfg(all(Py_3_14, not(any(PyPy, GraalPy))))]
108+
context_watcher::register(m)?;
109+
105110
Ok(())
106111
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import contextvars
2+
import sys
3+
import threading
4+
5+
import pytest
6+
7+
from ddtrace.internal import core
8+
9+
10+
pytestmark = pytest.mark.skipif(
11+
sys.implementation.name != "cpython" or sys.version_info < (3, 14),
12+
reason="requires the CPython 3.14 context watcher",
13+
)
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def _register_context_watcher():
18+
from ddtrace.internal.native._native import is_context_watcher_registered
19+
from ddtrace.internal.native._native import register_context_watcher
20+
21+
assert register_context_watcher()
22+
assert is_context_watcher_registered()
23+
24+
25+
def test_context_watcher_dispatches_events_and_releases_listener_snapshot():
26+
value = contextvars.ContextVar("value", default="outer")
27+
inner_context = contextvars.copy_context()
28+
inner_context.run(value.set, "inner")
29+
observed = []
30+
test_thread_id = threading.get_ident()
31+
32+
def record_context_switch():
33+
if threading.get_ident() == test_thread_id:
34+
observed.append(value.get())
35+
36+
core.on("python.context.switch", record_context_switch)
37+
try:
38+
reference_count = sys.getrefcount(record_context_switch)
39+
inner_context.run(lambda: None)
40+
assert sys.getrefcount(record_context_switch) == reference_count
41+
finally:
42+
core.reset_listeners("python.context.switch", record_context_switch)
43+
44+
assert observed == ["inner", "outer"]
45+
46+
47+
def test_context_watcher_preserves_pending_exception_over_listener_failure():
48+
inner_context = contextvars.copy_context()
49+
observed = []
50+
unraisable = []
51+
test_thread_id = threading.get_ident()
52+
53+
class ExpectedError(Exception):
54+
pass
55+
56+
class ListenerError(BaseException):
57+
pass
58+
59+
def record_context_switch():
60+
if threading.get_ident() != test_thread_id:
61+
return
62+
observed.append(contextvars.copy_context())
63+
if len(observed) == 2:
64+
raise ListenerError
65+
66+
def raise_expected_error():
67+
raise ExpectedError
68+
69+
original_unraisablehook = sys.unraisablehook
70+
sys.unraisablehook = lambda args: unraisable.append(args.exc_value)
71+
core.on("python.context.switch", record_context_switch)
72+
try:
73+
with pytest.raises(ExpectedError):
74+
inner_context.run(raise_expected_error)
75+
finally:
76+
core.reset_listeners("python.context.switch", record_context_switch)
77+
sys.unraisablehook = original_unraisablehook
78+
79+
assert len(observed) == 2
80+
assert len(unraisable) == 1
81+
assert isinstance(unraisable[0], ListenerError)
82+
83+
84+
@pytest.mark.subprocess(env={"_DD_GLOBAL_TRACER_INIT": "false"})
85+
def test_context_watcher_slot_exhaustion_disables_watcher():
86+
"""Registration failure is non-fatal and remains cached after watcher slots are freed."""
87+
import ctypes
88+
import sys
89+
90+
assert "ddtrace.internal.native._native" not in sys.modules
91+
92+
callback_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_uint, ctypes.py_object)
93+
callback = callback_type(lambda event, obj: 0)
94+
add_watcher = ctypes.pythonapi.PyContext_AddWatcher
95+
add_watcher.argtypes = [callback_type]
96+
add_watcher.restype = ctypes.c_int
97+
clear_watcher = ctypes.pythonapi.PyContext_ClearWatcher
98+
clear_watcher.argtypes = [ctypes.c_int]
99+
clear_watcher.restype = ctypes.c_int
100+
101+
watcher_ids = []
102+
for _ in range(64):
103+
try:
104+
watcher_ids.append(add_watcher(callback))
105+
except RuntimeError:
106+
break
107+
else:
108+
raise AssertionError("context watcher slots were not exhausted")
109+
110+
try:
111+
from ddtrace.internal.native._native import is_context_watcher_registered
112+
from ddtrace.internal.native._native import register_context_watcher
113+
114+
assert register_context_watcher() is False
115+
assert is_context_watcher_registered() is False
116+
finally:
117+
for watcher_id in watcher_ids:
118+
assert clear_watcher(watcher_id) == 0
119+
120+
assert register_context_watcher() is False
121+
assert is_context_watcher_registered() is False
122+
123+
124+
@pytest.mark.subprocess(env={"_DD_GLOBAL_TRACER_INIT": "false"})
125+
def test_context_watcher_registration_is_idempotent():
126+
"""Repeated registration uses one watcher; tracer startup is disabled so this test owns the first call."""
127+
from contextvars import Context
128+
129+
from ddtrace.internal import core
130+
from ddtrace.internal.native._native import is_context_watcher_registered
131+
from ddtrace.internal.native._native import register_context_watcher
132+
133+
assert is_context_watcher_registered() is False
134+
135+
for _ in range(16):
136+
assert register_context_watcher() is True
137+
138+
assert is_context_watcher_registered() is True
139+
140+
observed = []
141+
core.on("python.context.switch", lambda: observed.append(None))
142+
Context().run(lambda: None)
143+
assert observed == [None, None]

0 commit comments

Comments
 (0)