Skip to content

Commit cb6b686

Browse files
authored
New Scope implementation based on OTel Context (#3389)
* New `PotelScope` inherits from scope and reads the scope from the otel context key `SENTRY_SCOPES_KEY` * New `isolation_scope` and `new_scope` context managers just use the context manager forking and yield with the scopes living on the above context key * isolation scope forking is done with the `SENTRY_FORK_ISOLATION_SCOPE_KEY` boolean context key
1 parent 25914a5 commit cb6b686

File tree

9 files changed

+223
-33
lines changed

9 files changed

+223
-33
lines changed

sentry_sdk/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
from sentry_sdk import tracing, tracing_utils, Client
66
from sentry_sdk._init_implementation import init
7-
from sentry_sdk.scope import Scope, _ScopeManager, new_scope, isolation_scope
87
from sentry_sdk.tracing import POTelSpan, Transaction, trace
98
from sentry_sdk.crons import monitor
9+
# TODO-neel-potel make 2 scope strategies/impls and switch
10+
from sentry_sdk.integrations.opentelemetry.scope import PotelScope as Scope, new_scope, isolation_scope
1011

1112
from sentry_sdk._types import TYPE_CHECKING
1213

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401
2-
SentrySpanProcessor,
3-
)
1+
# TODO-neel-potel fix circular imports
2+
# from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401
3+
# SentrySpanProcessor,
4+
# )
45

5-
from sentry_sdk.integrations.opentelemetry.propagator import ( # noqa: F401
6-
SentryPropagator,
7-
)
6+
# from sentry_sdk.integrations.opentelemetry.propagator import ( # noqa: F401
7+
# SentryPropagator,
8+
# )

sentry_sdk/integrations/opentelemetry/consts.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from opentelemetry.context import create_key
22

33

4+
# propagation keys
45
SENTRY_TRACE_KEY = create_key("sentry-trace")
56
SENTRY_BAGGAGE_KEY = create_key("sentry-baggage")
7+
8+
# scope management keys
9+
SENTRY_SCOPES_KEY = create_key("sentry_scopes")
10+
SENTRY_FORK_ISOLATION_SCOPE_KEY = create_key("sentry_fork_isolation_scope")
11+
612
OTEL_SENTRY_CONTEXT = "otel"
713
SPAN_ORIGIN = "auto.otel"
814

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
1-
from opentelemetry.context import Context, create_key, get_value, set_value
1+
from opentelemetry.context import Context, get_value, set_value
22
from opentelemetry.context.contextvars_context import ContextVarsRuntimeContext
33

4-
from sentry_sdk.scope import Scope
5-
6-
7-
_SCOPES_KEY = create_key("sentry_scopes")
4+
import sentry_sdk
5+
from sentry_sdk.integrations.opentelemetry.consts import (
6+
SENTRY_SCOPES_KEY,
7+
SENTRY_FORK_ISOLATION_SCOPE_KEY,
8+
)
89

910

1011
class SentryContextVarsRuntimeContext(ContextVarsRuntimeContext):
1112
def attach(self, context):
1213
# type: (Context) -> object
13-
scopes = get_value(_SCOPES_KEY, context)
14+
scopes = get_value(SENTRY_SCOPES_KEY, context)
15+
should_fork_isolation_scope = context.pop(
16+
SENTRY_FORK_ISOLATION_SCOPE_KEY, False
17+
)
1418

1519
if scopes and isinstance(scopes, tuple):
1620
(current_scope, isolation_scope) = scopes
1721
else:
18-
current_scope = Scope.get_current_scope()
19-
isolation_scope = Scope.get_isolation_scope()
22+
current_scope = sentry_sdk.get_current_scope()
23+
isolation_scope = sentry_sdk.get_isolation_scope()
2024

21-
# TODO-neel-potel fork isolation_scope too like JS
22-
# once we setup our own apis to pass through to otel
23-
new_scopes = (current_scope.fork(), isolation_scope)
24-
new_context = set_value(_SCOPES_KEY, new_scopes, context)
25+
new_scope = current_scope.fork()
26+
new_isolation_scope = (
27+
isolation_scope.fork() if should_fork_isolation_scope else isolation_scope
28+
)
29+
new_scopes = (new_scope, new_isolation_scope)
2530

31+
new_context = set_value(SENTRY_SCOPES_KEY, new_scopes, context)
2632
return super().attach(new_context)

sentry_sdk/integrations/opentelemetry/integration.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66

77
from sentry_sdk.integrations import DidNotEnable, Integration
88
from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator
9-
from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor
9+
from sentry_sdk.integrations.opentelemetry.potel_span_processor import (
10+
PotelSentrySpanProcessor,
11+
)
12+
from sentry_sdk.integrations.opentelemetry.contextvars_context import (
13+
SentryContextVarsRuntimeContext,
14+
)
1015
from sentry_sdk.utils import logger
1116

1217
try:
@@ -46,9 +51,14 @@ def setup_once():
4651

4752
def _setup_sentry_tracing():
4853
# type: () -> None
54+
import opentelemetry.context
55+
56+
opentelemetry.context._RUNTIME_CONTEXT = SentryContextVarsRuntimeContext()
57+
4958
provider = TracerProvider()
50-
provider.add_span_processor(SentrySpanProcessor())
59+
provider.add_span_processor(PotelSentrySpanProcessor())
5160
trace.set_tracer_provider(provider)
61+
5262
set_global_textmap(SentryPropagator())
5363

5464

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from typing import cast
2+
from contextlib import contextmanager
3+
4+
from opentelemetry.context import get_value, set_value, attach, detach, get_current
5+
6+
from sentry_sdk.scope import Scope, ScopeType
7+
from sentry_sdk.integrations.opentelemetry.consts import (
8+
SENTRY_SCOPES_KEY,
9+
SENTRY_FORK_ISOLATION_SCOPE_KEY,
10+
)
11+
12+
from sentry_sdk._types import TYPE_CHECKING
13+
14+
if TYPE_CHECKING:
15+
from typing import Tuple, Optional, Generator
16+
17+
18+
class PotelScope(Scope):
19+
@classmethod
20+
def _get_scopes(cls):
21+
# type: () -> Optional[Tuple[Scope, Scope]]
22+
"""
23+
Returns the current scopes tuple on the otel context. Internal use only.
24+
"""
25+
return cast("Optional[Tuple[Scope, Scope]]", get_value(SENTRY_SCOPES_KEY))
26+
27+
@classmethod
28+
def get_current_scope(cls):
29+
# type: () -> Scope
30+
"""
31+
Returns the current scope.
32+
"""
33+
return cls._get_current_scope() or _INITIAL_CURRENT_SCOPE
34+
35+
@classmethod
36+
def _get_current_scope(cls):
37+
# type: () -> Optional[Scope]
38+
"""
39+
Returns the current scope without creating a new one. Internal use only.
40+
"""
41+
scopes = cls._get_scopes()
42+
return scopes[0] if scopes else None
43+
44+
@classmethod
45+
def get_isolation_scope(cls):
46+
"""
47+
Returns the isolation scope.
48+
"""
49+
# type: () -> Scope
50+
return cls._get_isolation_scope() or _INITIAL_ISOLATION_SCOPE
51+
52+
@classmethod
53+
def _get_isolation_scope(cls):
54+
# type: () -> Optional[Scope]
55+
"""
56+
Returns the isolation scope without creating a new one. Internal use only.
57+
"""
58+
scopes = cls._get_scopes()
59+
return scopes[1] if scopes else None
60+
61+
62+
_INITIAL_CURRENT_SCOPE = PotelScope(ty=ScopeType.CURRENT)
63+
_INITIAL_ISOLATION_SCOPE = PotelScope(ty=ScopeType.ISOLATION)
64+
65+
66+
@contextmanager
67+
def isolation_scope():
68+
# type: () -> Generator[Scope, None, None]
69+
context = set_value(SENTRY_FORK_ISOLATION_SCOPE_KEY, True)
70+
token = attach(context)
71+
try:
72+
yield PotelScope.get_isolation_scope()
73+
finally:
74+
detach(token)
75+
76+
77+
@contextmanager
78+
def new_scope():
79+
# type: () -> Generator[Scope, None, None]
80+
token = attach(get_current())
81+
try:
82+
yield PotelScope.get_current_scope()
83+
finally:
84+
detach(token)

sentry_sdk/scope.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -255,13 +255,21 @@ def get_current_scope(cls):
255255
256256
Returns the current scope.
257257
"""
258-
current_scope = _current_scope.get()
258+
current_scope = cls._get_current_scope()
259259
if current_scope is None:
260260
current_scope = Scope(ty=ScopeType.CURRENT)
261261
_current_scope.set(current_scope)
262262

263263
return current_scope
264264

265+
@classmethod
266+
def _get_current_scope(cls):
267+
# type: () -> Optional[Scope]
268+
"""
269+
Returns the current scope without creating a new one. Internal use only.
270+
"""
271+
return _current_scope.get()
272+
265273
@classmethod
266274
def set_current_scope(cls, new_current_scope):
267275
# type: (Scope) -> None
@@ -281,13 +289,21 @@ def get_isolation_scope(cls):
281289
282290
Returns the isolation scope.
283291
"""
284-
isolation_scope = _isolation_scope.get()
292+
isolation_scope = cls._get_isolation_scope()
285293
if isolation_scope is None:
286294
isolation_scope = Scope(ty=ScopeType.ISOLATION)
287295
_isolation_scope.set(isolation_scope)
288296

289297
return isolation_scope
290298

299+
@classmethod
300+
def _get_isolation_scope(cls):
301+
# type: () -> Optional[Scope]
302+
"""
303+
Returns the isolation scope without creating a new one. Internal use only.
304+
"""
305+
return _isolation_scope.get()
306+
291307
@classmethod
292308
def set_isolation_scope(cls, new_isolation_scope):
293309
# type: (Scope) -> None
@@ -342,13 +358,11 @@ def _merge_scopes(self, additional_scope=None, additional_scope_kwargs=None):
342358
final_scope = copy(_global_scope) if _global_scope is not None else Scope()
343359
final_scope._type = ScopeType.MERGED
344360

345-
isolation_scope = _isolation_scope.get()
346-
if isolation_scope is not None:
347-
final_scope.update_from_scope(isolation_scope)
361+
isolation_scope = self.get_isolation_scope()
362+
final_scope.update_from_scope(isolation_scope)
348363

349-
current_scope = _current_scope.get()
350-
if current_scope is not None:
351-
final_scope.update_from_scope(current_scope)
364+
current_scope = self.get_current_scope()
365+
final_scope.update_from_scope(current_scope)
352366

353367
if self != current_scope and self != isolation_scope:
354368
final_scope.update_from_scope(self)
@@ -374,7 +388,7 @@ def get_client(cls):
374388
This checks the current scope, the isolation scope and the global scope for a client.
375389
If no client is available a :py:class:`sentry_sdk.client.NonRecordingClient` is returned.
376390
"""
377-
current_scope = _current_scope.get()
391+
current_scope = cls._get_current_scope()
378392
try:
379393
client = current_scope.client
380394
except AttributeError:
@@ -383,7 +397,7 @@ def get_client(cls):
383397
if client is not None and client.is_active():
384398
return client
385399

386-
isolation_scope = _isolation_scope.get()
400+
isolation_scope = cls._get_isolation_scope()
387401
try:
388402
client = isolation_scope.client
389403
except AttributeError:
@@ -1361,8 +1375,8 @@ def run_event_processors(self, event, hint):
13611375

13621376
if not is_check_in:
13631377
# Get scopes without creating them to prevent infinite recursion
1364-
isolation_scope = _isolation_scope.get()
1365-
current_scope = _current_scope.get()
1378+
isolation_scope = self._get_isolation_scope()
1379+
current_scope = self._get_current_scope()
13661380

13671381
event_processors = chain(
13681382
global_event_processors,

tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def benchmark():
6363

6464

6565
from sentry_sdk import scope
66+
import sentry_sdk.integrations.opentelemetry.scope as potel_scope
6667

6768

6869
@pytest.fixture(autouse=True)
@@ -74,6 +75,9 @@ def clean_scopes():
7475
scope._isolation_scope.set(None)
7576
scope._current_scope.set(None)
7677

78+
potel_scope._INITIAL_CURRENT_SCOPE.clear()
79+
potel_scope._INITIAL_ISOLATION_SCOPE.clear()
80+
7781

7882
@pytest.fixture(autouse=True)
7983
def internal_exceptions(request):

tests/integrations/opentelemetry/test_potel.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,67 @@ def test_span_data_started_with_sentry(capture_envelopes):
250250
"sentry.description": "statement",
251251
"sentry.op": "db",
252252
}
253+
254+
255+
def test_transaction_tags_started_with_otel(capture_envelopes):
256+
envelopes = capture_envelopes()
257+
258+
sentry_sdk.set_tag("tag.global", 99)
259+
with tracer.start_as_current_span("request"):
260+
sentry_sdk.set_tag("tag.inner", "foo")
261+
262+
(envelope,) = envelopes
263+
(item,) = envelope.items
264+
payload = item.payload.json
265+
266+
assert payload["tags"] == {"tag.global": 99, "tag.inner": "foo"}
267+
268+
269+
def test_transaction_tags_started_with_sentry(capture_envelopes):
270+
envelopes = capture_envelopes()
271+
272+
sentry_sdk.set_tag("tag.global", 99)
273+
with sentry_sdk.start_span(description="request"):
274+
sentry_sdk.set_tag("tag.inner", "foo")
275+
276+
(envelope,) = envelopes
277+
(item,) = envelope.items
278+
payload = item.payload.json
279+
280+
assert payload["tags"] == {"tag.global": 99, "tag.inner": "foo"}
281+
282+
283+
def test_multiple_transaction_tags_isolation_scope_started_with_otel(capture_envelopes):
284+
envelopes = capture_envelopes()
285+
286+
sentry_sdk.set_tag("tag.global", 99)
287+
with sentry_sdk.isolation_scope():
288+
with tracer.start_as_current_span("request a"):
289+
sentry_sdk.set_tag("tag.inner.a", "a")
290+
with sentry_sdk.isolation_scope():
291+
with tracer.start_as_current_span("request b"):
292+
sentry_sdk.set_tag("tag.inner.b", "b")
293+
294+
(payload_a, payload_b) = [envelope.items[0].payload.json for envelope in envelopes]
295+
296+
assert payload_a["tags"] == {"tag.global": 99, "tag.inner.a": "a"}
297+
assert payload_b["tags"] == {"tag.global": 99, "tag.inner.b": "b"}
298+
299+
300+
def test_multiple_transaction_tags_isolation_scope_started_with_sentry(
301+
capture_envelopes,
302+
):
303+
envelopes = capture_envelopes()
304+
305+
sentry_sdk.set_tag("tag.global", 99)
306+
with sentry_sdk.isolation_scope():
307+
with sentry_sdk.start_span(description="request a"):
308+
sentry_sdk.set_tag("tag.inner.a", "a")
309+
with sentry_sdk.isolation_scope():
310+
with sentry_sdk.start_span(description="request b"):
311+
sentry_sdk.set_tag("tag.inner.b", "b")
312+
313+
(payload_a, payload_b) = [envelope.items[0].payload.json for envelope in envelopes]
314+
315+
assert payload_a["tags"] == {"tag.global": 99, "tag.inner.a": "a"}
316+
assert payload_b["tags"] == {"tag.global": 99, "tag.inner.b": "b"}

0 commit comments

Comments
 (0)