diff --git a/sentry_sdk/integrations/otlp.py b/sentry_sdk/integrations/otlp.py index 9ef1826c60..19c6099970 100644 --- a/sentry_sdk/integrations/otlp.py +++ b/sentry_sdk/integrations/otlp.py @@ -1,7 +1,12 @@ -from sentry_sdk import get_client +from sentry_sdk import get_client, capture_event from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.scope import register_external_propagation_context -from sentry_sdk.utils import logger, Dsn +from sentry_sdk.utils import ( + Dsn, + logger, + event_from_exception, + capture_internal_exceptions, +) from sentry_sdk.consts import VERSION, EndpointType from sentry_sdk.tracing_utils import Baggage from sentry_sdk.tracing import ( @@ -11,7 +16,7 @@ try: from opentelemetry.propagate import set_global_textmap - from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace import TracerProvider, Span from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter @@ -82,6 +87,38 @@ def setup_otlp_traces_exporter(dsn: "Optional[str]" = None) -> None: tracer_provider.add_span_processor(span_processor) +_sentry_patched_exception = False + + +def setup_capture_exceptions() -> None: + """ + Intercept otel's Span.record_exception to automatically capture those exceptions in Sentry. + """ + global _sentry_patched_exception + _original_record_exception = Span.record_exception + + if _sentry_patched_exception: + return + + def _sentry_patched_record_exception( + self: "Span", exception: "BaseException", *args: "Any", **kwargs: "Any" + ) -> None: + otlp_integration = get_client().get_integration(OTLPIntegration) + if otlp_integration and otlp_integration.capture_exceptions: + with capture_internal_exceptions(): + event, hint = event_from_exception( + exception, + client_options=get_client().options, + mechanism={"type": OTLPIntegration.identifier, "handled": False}, + ) + capture_event(event, hint=hint) + + _original_record_exception(self, exception, *args, **kwargs) + + Span.record_exception = _sentry_patched_record_exception # type: ignore[method-assign] + _sentry_patched_exception = True + + class SentryOTLPPropagator(SentryPropagator): """ We need to override the inject of the older propagator since that @@ -136,13 +173,28 @@ def _to_traceparent(span_context: "SpanContext") -> str: class OTLPIntegration(Integration): + """ + Automatically setup OTLP ingestion from the DSN. + + :param setup_otlp_traces_exporter: Automatically configure an Exporter to send OTLP traces from the DSN, defaults to True. + Set to False if using a custom collector or to setup the TracerProvider manually. + :param setup_propagator: Automatically configure the Sentry Propagator for Distributed Tracing, defaults to True. + Set to False to configure propagators manually or to disable propagation. + :param capture_exceptions: Intercept and capture exceptions on the OpenTelemetry Span in Sentry as well, defaults to False. + 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. + """ + identifier = "otlp" def __init__( - self, setup_otlp_traces_exporter: bool = True, setup_propagator: bool = True + self, + setup_otlp_traces_exporter: bool = True, + setup_propagator: bool = True, + capture_exceptions: bool = False, ) -> None: self.setup_otlp_traces_exporter = setup_otlp_traces_exporter self.setup_propagator = setup_propagator + self.capture_exceptions = capture_exceptions @staticmethod def setup_once() -> None: @@ -161,3 +213,5 @@ def setup_once_with_options( logger.debug("[OTLP] Setting up propagator for distributed tracing") # TODO-neel better propagator support, chain with existing ones if possible instead of replacing set_global_textmap(SentryOTLPPropagator()) + + setup_capture_exceptions() diff --git a/tests/integrations/otlp/test_otlp.py b/tests/integrations/otlp/test_otlp.py index d4208fb09d..191bf5b7f4 100644 --- a/tests/integrations/otlp/test_otlp.py +++ b/tests/integrations/otlp/test_otlp.py @@ -24,6 +24,25 @@ original_propagator = get_global_textmap() +@pytest.fixture(autouse=True) +def mock_otlp_ingest(): + responses.start() + responses.add( + responses.POST, + url="https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/", + status=200, + ) + + yield + + tracer_provider = get_tracer_provider() + if isinstance(tracer_provider, TracerProvider): + tracer_provider.force_flush() + + responses.stop() + responses.reset() + + @pytest.fixture(autouse=True) def reset_otlp(uninstall_integration): trace._TRACER_PROVIDER_SET_ONCE = Once() @@ -127,14 +146,7 @@ def test_does_not_set_propagator_if_disabled(sentry_init): assert propagator is original_propagator -@responses.activate def test_otel_propagation_context(sentry_init): - responses.add( - responses.POST, - url="https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/", - status=200, - ) - sentry_init( dsn="https://mysecret@bla.ingest.sentry.io/12312012", integrations=[OTLPIntegration()], @@ -145,9 +157,6 @@ def test_otel_propagation_context(sentry_init): with tracer.start_as_current_span("bar") as span: external_propagation_context = get_external_propagation_context() - # Force flush to ensure spans are exported while mock is active - get_tracer_provider().force_flush() - assert external_propagation_context is not None (trace_id, span_id) = external_propagation_context 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): assert carrier["baggage"] == incoming_headers["baggage"] detach(token) + + +def test_capture_exceptions_enabled(sentry_init, capture_events): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration(capture_exceptions=True)], + ) + + events = capture_events() + + tracer = trace.get_tracer(__name__) + with tracer.start_as_current_span("test_span") as span: + try: + raise ValueError("Test exception") + except ValueError as e: + span.record_exception(e) + + (event,) = events + assert event["exception"]["values"][0]["type"] == "ValueError" + assert event["exception"]["values"][0]["value"] == "Test exception" + assert event["exception"]["values"][0]["mechanism"]["type"] == "otlp" + assert event["exception"]["values"][0]["mechanism"]["handled"] is False + + trace_context = event["contexts"]["trace"] + assert trace_context["trace_id"] == format_trace_id( + span.get_span_context().trace_id + ) + assert trace_context["span_id"] == format_span_id(span.get_span_context().span_id) + + +def test_capture_exceptions_disabled(sentry_init, capture_events): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration(capture_exceptions=False)], + ) + + events = capture_events() + + tracer = trace.get_tracer(__name__) + with tracer.start_as_current_span("test_span") as span: + try: + raise ValueError("Test exception") + except ValueError as e: + span.record_exception(e) + + assert len(events) == 0 + + +def test_capture_exceptions_preserves_otel_behavior(sentry_init, capture_events): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration(capture_exceptions=True)], + ) + + events = capture_events() + + tracer = trace.get_tracer(__name__) + with tracer.start_as_current_span("test_span") as span: + try: + raise ValueError("Test exception") + except ValueError as e: + span.record_exception(e, attributes={"foo": "bar"}) + + # Verify the span recorded the exception (OpenTelemetry behavior) + # The span should have events with the exception information + (otel_event,) = span._events + assert otel_event.name == "exception" + assert otel_event.attributes["foo"] == "bar" + + # verify sentry also captured it + assert len(events) == 1