Skip to content

Commit 1c49f12

Browse files
committed
feat(otel): support deactivating active traces
1 parent 1f1ac6a commit 1c49f12

File tree

5 files changed

+94
-10
lines changed

5 files changed

+94
-10
lines changed

ddtrace/_trace/provider.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import abc
22
import contextvars
3+
from contextvars import Token
34
from typing import Any
45
from typing import Optional
56
from typing import Union
@@ -63,10 +64,11 @@ def _has_active_context(self) -> bool:
6364
ctx = _DD_CONTEXTVAR.get()
6465
return ctx is not None
6566

66-
def activate(self, ctx: Optional[ActiveTrace]) -> None:
67+
def activate(self, ctx: Optional[ActiveTrace]) -> Token:
6768
"""Makes the given context active in the current execution."""
68-
_DD_CONTEXTVAR.set(ctx)
69+
token = _DD_CONTEXTVAR.set(ctx)
6970
super(DefaultContextProvider, self).activate(ctx)
71+
return token
7072

7173
def active(self) -> Optional[ActiveTrace]:
7274
"""Returns the active span or context for the current execution."""
@@ -95,3 +97,6 @@ def _update_active(self, span: Span) -> Optional[ActiveTrace]:
9597
if new_active is not span:
9698
self.activate(new_active)
9799
return new_active
100+
101+
def _deactivate(self, token: Token) -> None:
102+
_DD_CONTEXTVAR.reset(token)

ddtrace/internal/opentelemetry/context.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ def attach(self, otel_context):
3030
otel_span = get_current_span(otel_context)
3131
if otel_span:
3232
if isinstance(otel_span, Span):
33-
self._ddcontext_provider.activate(otel_span._ddspan)
33+
token = self._ddcontext_provider.activate(otel_span._ddspan)
3434
ddcontext = otel_span._ddspan.context
3535
elif isinstance(otel_span, OtelSpan):
3636
trace_id, span_id, _, tf, ts, _ = otel_span.get_span_context()
3737
trace_state = ts.to_header() if ts else None
3838
ddcontext = _TraceContext._get_context(trace_id, span_id, tf, trace_state)
39-
self._ddcontext_provider.activate(ddcontext)
39+
token = self._ddcontext_provider.activate(ddcontext)
4040
else:
41+
token = None
4142
ddcontext = None
4243
log.error(
4344
"Programming ERROR: ddtrace does not support activating spans with the type: %s. Please open a "
@@ -54,9 +55,7 @@ def attach(self, otel_context):
5455
for key, value in otel_baggage.items():
5556
ddcontext._baggage[key] = value # potentially convert to json
5657

57-
# A return value with the type `object` is required by the otel api to remove/deactivate spans.
58-
# Since manually deactivating spans is not supported by ddtrace this object will never be used.
59-
return object()
58+
return token
6059

6160
def get_current(self):
6261
"""
@@ -100,10 +99,9 @@ def get_current(self):
10099

101100
def detach(self, token):
102101
"""
103-
NOP, The otel api uses this method to deactivate spans but this operation is not supported by
104-
the datadog context provider.
102+
Resets the datadog contextvar to the previous active context.
105103
"""
106-
pass
104+
self._ddcontext_provider._deactivate(token)
107105

108106
@property
109107
def _ddcontext_provider(self):
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
features:
3+
- |
4+
opentelemetry: Added support for nested context activation and deactivation using ``attach()`` and ``detach()`` from the OpenTelemetry context API. Deactivating a context properly restores the previous active trace.
5+
Note: ``detach()`` only supports deactivating the last active trace and baggage. Other signals present in the context will not be restored.

tests/opentelemetry/test_context.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
import time
33

44
import opentelemetry
5+
from opentelemetry import context as context_api
56
from opentelemetry.baggage import get_baggage
67
from opentelemetry.baggage import remove_baggage
78
from opentelemetry.baggage import set_baggage
9+
from opentelemetry.context import Context
810
from opentelemetry.context import attach
11+
from opentelemetry.trace import get_current_span
12+
from opentelemetry.trace import set_span_in_context
13+
from opentelemetry.trace.span import INVALID_SPAN
914
import pytest
1015

1116
import ddtrace
@@ -229,3 +234,39 @@ def test_providers_are_set():
229234
assert tracer_provider.get_tracer(__name__) is not None
230235
assert meter_provider.get_meter(__name__) is not None
231236
assert logger_provider.get_logger(__name__) is not None
237+
238+
239+
def test_otel_context_api(oteltracer):
240+
"""Validates context activation and deactivation using the OpenTelemetry context API."""
241+
# Verify test starts with clean context
242+
assert get_current_span() is INVALID_SPAN
243+
244+
try:
245+
# Attach span1 and verify it becomes active
246+
span1 = oteltracer.start_span("span1")
247+
token1 = context_api.attach(set_span_in_context(span1))
248+
assert get_current_span()._ddspan == span1._ddspan
249+
# Attach span2 and verify it becomes active (nested)
250+
span2 = oteltracer.start_span("span2")
251+
token2 = context_api.attach(set_span_in_context(span2))
252+
assert get_current_span()._ddspan == span2._ddspan
253+
# Attach span3 and verify it becomes active (double-nested)
254+
span3 = oteltracer.start_span("span3")
255+
token3 = context_api.attach(set_span_in_context(span3))
256+
assert get_current_span()._ddspan == span3._ddspan
257+
# Detach span3, span2 should become active again
258+
context_api.detach(token3)
259+
assert get_current_span()._ddspan == span2._ddspan
260+
# Detach span2, span1 should become active again
261+
context_api.detach(token2)
262+
assert get_current_span()._ddspan == span1._ddspan
263+
# Detach span1, no active span should remain
264+
context_api.detach(token1)
265+
assert get_current_span() is INVALID_SPAN
266+
# End spans to clean up
267+
span1.end()
268+
span2.end()
269+
span3.end()
270+
finally:
271+
# Clear context to prevent leaking spans to subsequent tests
272+
context_api.attach(Context())

tests/tracer/test_tracer.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2042,3 +2042,38 @@ def test_activate_context_nesting_and_restoration(tracer):
20422042
assert active.span_id == 1
20432043

20442044
assert tracer.context_provider.active() is None
2045+
2046+
2047+
def test_activate_and_deactivate_active_trace(tracer):
2048+
"""Test that deactivating an active trace properly restores the previous active trace."""
2049+
# Verify test starts with clean context
2050+
assert tracer.context_provider.active() is None
2051+
2052+
try:
2053+
# Activate span1 and verify it becomes active
2054+
span1 = tracer.start_span("span1")
2055+
token1 = tracer.context_provider.activate(span1)
2056+
assert tracer.context_provider.active() == span1
2057+
# Activate span2 and verify it becomes active (nested)
2058+
span2 = tracer.start_span("span2")
2059+
token2 = tracer.context_provider.activate(span2)
2060+
assert tracer.context_provider.active() == span2
2061+
# Activate a context and verify it becomes active (double-nested)
2062+
context3 = Context(trace_id=3, span_id=3)
2063+
token3 = tracer.context_provider.activate(context3)
2064+
assert tracer.context_provider.active() == context3
2065+
# Deactivate span3, span2 should become active again
2066+
tracer.context_provider._deactivate(token3)
2067+
assert tracer.context_provider.active() == span2
2068+
# Deactivate span2, span1 should become active again
2069+
tracer.context_provider._deactivate(token2)
2070+
assert tracer.context_provider.active() == span1
2071+
# Deactivate span1, no active span should remain
2072+
tracer.context_provider._deactivate(token1)
2073+
assert tracer.context_provider.active() is None
2074+
# End spans to clean up
2075+
span1.finish()
2076+
span2.finish()
2077+
finally:
2078+
# Clear context to prevent leaking spans to subsequent tests
2079+
tracer.context_provider.activate(None)

0 commit comments

Comments
 (0)