Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions sentry_sdk/integrations/otlp.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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()
100 changes: 90 additions & 10 deletions tests/integrations/otlp/test_otlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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://[email protected]/12312012",
integrations=[OTLPIntegration()],
Expand All @@ -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)
Expand Down Expand Up @@ -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://[email protected]/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://[email protected]/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://[email protected]/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