Skip to content

Commit 03153c2

Browse files
committed
feat(otlp): Optionally capture exceptions from otel's Span.record_exception api
1 parent eedd101 commit 03153c2

File tree

2 files changed

+142
-14
lines changed

2 files changed

+142
-14
lines changed

sentry_sdk/integrations/otlp.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from sentry_sdk import get_client
1+
from sentry_sdk import get_client, capture_event
22
from sentry_sdk.integrations import Integration, DidNotEnable
33
from sentry_sdk.scope import register_external_propagation_context
4-
from sentry_sdk.utils import logger, Dsn
4+
from sentry_sdk.utils import Dsn, logger, event_from_exception
55
from sentry_sdk.consts import VERSION, EndpointType
66
from sentry_sdk.tracing_utils import Baggage
77
from sentry_sdk.tracing import (
@@ -11,7 +11,7 @@
1111

1212
try:
1313
from opentelemetry.propagate import set_global_textmap
14-
from opentelemetry.sdk.trace import TracerProvider
14+
from opentelemetry.sdk.trace import TracerProvider, Span
1515
from opentelemetry.sdk.trace.export import BatchSpanProcessor
1616
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
1717

@@ -82,6 +82,37 @@ def setup_otlp_traces_exporter(dsn: "Optional[str]" = None) -> None:
8282
tracer_provider.add_span_processor(span_processor)
8383

8484

85+
_sentry_patched_exception = False
86+
87+
88+
def setup_capture_exceptions() -> None:
89+
"""
90+
Intercept otel's Span.record_exception to automatically capture those exceptions in Sentry.
91+
"""
92+
global _sentry_patched_exception
93+
_original_record_exception = Span.record_exception
94+
95+
if _sentry_patched_exception:
96+
return
97+
98+
def _sentry_patched_record_exception(
99+
self: "Span", exception: "BaseException", *args: "Any", **kwargs: "Any"
100+
) -> None:
101+
otlp_integration = get_client().get_integration(OTLPIntegration)
102+
if otlp_integration and otlp_integration.capture_exceptions:
103+
event, hint = event_from_exception(
104+
exception,
105+
client_options=get_client().options,
106+
mechanism={"type": OTLPIntegration.identifier, "handled": False},
107+
)
108+
capture_event(event, hint=hint)
109+
110+
_original_record_exception(self, exception, *args, **kwargs)
111+
112+
Span.record_exception = _sentry_patched_record_exception # type: ignore[method-assign]
113+
_sentry_patched_exception = True
114+
115+
85116
class SentryOTLPPropagator(SentryPropagator):
86117
"""
87118
We need to override the inject of the older propagator since that
@@ -136,13 +167,28 @@ def _to_traceparent(span_context: "SpanContext") -> str:
136167

137168

138169
class OTLPIntegration(Integration):
170+
"""
171+
Automatically setup OTLP ingestion from the DSN.
172+
173+
:param setup_otlp_traces_exporter: Automatically configure an Exporter to send OTLP traces from the DSN, defaults to True.
174+
Set to False if using a custom collector or to setup the TracerProvider manually.
175+
:param setup_propagator: Automatically configure the Sentry Propagator for Distributed Tracing, defaults to True.
176+
Set to False to configure propagators manually or to disable propagation.
177+
:param capture_exceptions: Intercept and capture exceptions on the OpenTelemetry Span in Sentry as well, defaults to False.
178+
Set to True to turn on capturing but be aware that since Sentry captures most exceptions, duplicate exceptions might be dropped by DedupeIntegration in many cases.
179+
"""
180+
139181
identifier = "otlp"
140182

141183
def __init__(
142-
self, setup_otlp_traces_exporter: bool = True, setup_propagator: bool = True
184+
self,
185+
setup_otlp_traces_exporter: bool = True,
186+
setup_propagator: bool = True,
187+
capture_exceptions: bool = False,
143188
) -> None:
144189
self.setup_otlp_traces_exporter = setup_otlp_traces_exporter
145190
self.setup_propagator = setup_propagator
191+
self.capture_exceptions = capture_exceptions
146192

147193
@staticmethod
148194
def setup_once() -> None:
@@ -161,3 +207,5 @@ def setup_once_with_options(
161207
logger.debug("[OTLP] Setting up propagator for distributed tracing")
162208
# TODO-neel better propagator support, chain with existing ones if possible instead of replacing
163209
set_global_textmap(SentryOTLPPropagator())
210+
211+
setup_capture_exceptions()

tests/integrations/otlp/test_otlp.py

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,25 @@
2424
original_propagator = get_global_textmap()
2525

2626

27+
@pytest.fixture(autouse=True)
28+
def mock_otlp_ingest():
29+
responses.start()
30+
responses.add(
31+
responses.POST,
32+
url="https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/",
33+
status=200,
34+
)
35+
36+
yield
37+
38+
tracer_provider = get_tracer_provider()
39+
if isinstance(tracer_provider, TracerProvider):
40+
tracer_provider.force_flush()
41+
42+
responses.stop()
43+
responses.reset()
44+
45+
2746
@pytest.fixture(autouse=True)
2847
def reset_otlp(uninstall_integration):
2948
trace._TRACER_PROVIDER_SET_ONCE = Once()
@@ -127,14 +146,7 @@ def test_does_not_set_propagator_if_disabled(sentry_init):
127146
assert propagator is original_propagator
128147

129148

130-
@responses.activate
131149
def test_otel_propagation_context(sentry_init):
132-
responses.add(
133-
responses.POST,
134-
url="https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/",
135-
status=200,
136-
)
137-
138150
sentry_init(
139151
dsn="https://[email protected]/12312012",
140152
integrations=[OTLPIntegration()],
@@ -145,9 +157,6 @@ def test_otel_propagation_context(sentry_init):
145157
with tracer.start_as_current_span("bar") as span:
146158
external_propagation_context = get_external_propagation_context()
147159

148-
# Force flush to ensure spans are exported while mock is active
149-
get_tracer_provider().force_flush()
150-
151160
assert external_propagation_context is not None
152161
(trace_id, span_id) = external_propagation_context
153162
assert trace_id == format_trace_id(root_span.get_span_context().trace_id)
@@ -222,3 +231,74 @@ def test_propagator_inject_continue_trace(sentry_init):
222231
assert carrier["baggage"] == incoming_headers["baggage"]
223232

224233
detach(token)
234+
235+
236+
def test_capture_exceptions_enabled(sentry_init, capture_events):
237+
sentry_init(
238+
dsn="https://[email protected]/12312012",
239+
integrations=[OTLPIntegration(capture_exceptions=True)],
240+
)
241+
242+
events = capture_events()
243+
244+
tracer = trace.get_tracer(__name__)
245+
with tracer.start_as_current_span("test_span") as span:
246+
try:
247+
raise ValueError("Test exception")
248+
except ValueError as e:
249+
span.record_exception(e)
250+
251+
(event,) = events
252+
assert event["exception"]["values"][0]["type"] == "ValueError"
253+
assert event["exception"]["values"][0]["value"] == "Test exception"
254+
assert event["exception"]["values"][0]["mechanism"]["type"] == "otlp"
255+
assert event["exception"]["values"][0]["mechanism"]["handled"] is False
256+
257+
trace_context = event["contexts"]["trace"]
258+
assert trace_context["trace_id"] == format_trace_id(
259+
span.get_span_context().trace_id
260+
)
261+
assert trace_context["span_id"] == format_span_id(span.get_span_context().span_id)
262+
263+
264+
def test_capture_exceptions_disabled(sentry_init, capture_events):
265+
sentry_init(
266+
dsn="https://[email protected]/12312012",
267+
integrations=[OTLPIntegration(capture_exceptions=False)],
268+
)
269+
270+
events = capture_events()
271+
272+
tracer = trace.get_tracer(__name__)
273+
with tracer.start_as_current_span("test_span") as span:
274+
try:
275+
raise ValueError("Test exception")
276+
except ValueError as e:
277+
span.record_exception(e)
278+
279+
assert len(events) == 0
280+
281+
282+
def test_capture_exceptions_preserves_otel_behavior(sentry_init, capture_events):
283+
sentry_init(
284+
dsn="https://[email protected]/12312012",
285+
integrations=[OTLPIntegration(capture_exceptions=True)],
286+
)
287+
288+
events = capture_events()
289+
290+
tracer = trace.get_tracer(__name__)
291+
with tracer.start_as_current_span("test_span") as span:
292+
try:
293+
raise ValueError("Test exception")
294+
except ValueError as e:
295+
span.record_exception(e, attributes={"foo": "bar"})
296+
297+
# Verify the span recorded the exception (OpenTelemetry behavior)
298+
# The span should have events with the exception information
299+
(otel_event,) = span._events
300+
assert otel_event.name == "exception"
301+
assert otel_event.attributes["foo"] == "bar"
302+
303+
# verify sentry also captured it
304+
assert len(events) == 1

0 commit comments

Comments
 (0)