From fa326912e63b3cc95bfe4984d099b293dd32b3f1 Mon Sep 17 00:00:00 2001 From: Neel Shah Date: Fri, 6 Mar 2026 13:40:54 +0100 Subject: [PATCH] feat(otlp): Add collector_url option to OTLPIntegration Co-Authored-By: Claude Opus 4.6 --- sentry_sdk/integrations/otlp.py | 17 +++++-- tests/integrations/otlp/test_otlp.py | 66 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/otlp.py b/sentry_sdk/integrations/otlp.py index 19c6099970..47556f9b80 100644 --- a/sentry_sdk/integrations/otlp.py +++ b/sentry_sdk/integrations/otlp.py @@ -66,7 +66,9 @@ def otel_propagation_context() -> "Optional[Tuple[str, str]]": return (format_trace_id(ctx.trace_id), format_span_id(ctx.span_id)) -def setup_otlp_traces_exporter(dsn: "Optional[str]" = None) -> None: +def setup_otlp_traces_exporter( + dsn: "Optional[str]" = None, collector_url: "Optional[str]" = None +) -> None: tracer_provider = get_tracer_provider() if not isinstance(tracer_provider, TracerProvider): @@ -76,7 +78,10 @@ def setup_otlp_traces_exporter(dsn: "Optional[str]" = None) -> None: endpoint = None headers = None - if dsn: + if collector_url: + endpoint = collector_url + logger.debug(f"[OTLP] Sending traces to collector at {endpoint}") + elif dsn: auth = Dsn(dsn).to_auth(f"sentry.python/{VERSION}") endpoint = auth.get_api_url(EndpointType.OTLP_TRACES) headers = {"X-Sentry-Auth": auth.to_header()} @@ -177,7 +182,9 @@ 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. + Set to False to setup the TracerProvider manually. + :param collector_url: URL of your own OpenTelemetry collector, defaults to None. + When set, the exporter will send traces to this URL instead of the Sentry OTLP endpoint derived from the DSN. :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. @@ -189,10 +196,12 @@ class OTLPIntegration(Integration): def __init__( self, setup_otlp_traces_exporter: bool = True, + collector_url: "Optional[str]" = None, setup_propagator: bool = True, capture_exceptions: bool = False, ) -> None: self.setup_otlp_traces_exporter = setup_otlp_traces_exporter + self.collector_url = collector_url self.setup_propagator = setup_propagator self.capture_exceptions = capture_exceptions @@ -207,7 +216,7 @@ def setup_once_with_options( if self.setup_otlp_traces_exporter: logger.debug("[OTLP] Setting up OTLP exporter") dsn: "Optional[str]" = options.get("dsn") if options else None - setup_otlp_traces_exporter(dsn) + setup_otlp_traces_exporter(dsn, collector_url=self.collector_url) if self.setup_propagator: logger.debug("[OTLP] Setting up propagator for distributed tracing") diff --git a/tests/integrations/otlp/test_otlp.py b/tests/integrations/otlp/test_otlp.py index 191bf5b7f4..e085a22ac0 100644 --- a/tests/integrations/otlp/test_otlp.py +++ b/tests/integrations/otlp/test_otlp.py @@ -32,6 +32,11 @@ def mock_otlp_ingest(): url="https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/", status=200, ) + responses.add( + responses.POST, + url="https://my-collector.example.com/v1/traces", + status=200, + ) yield @@ -233,6 +238,67 @@ def test_propagator_inject_continue_trace(sentry_init): detach(token) +def test_collector_url_sets_endpoint(sentry_init): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[ + OTLPIntegration(collector_url="https://my-collector.example.com/v1/traces") + ], + ) + + tracer_provider = get_tracer_provider() + assert isinstance(tracer_provider, TracerProvider) + + (span_processor,) = tracer_provider._active_span_processor._span_processors + assert isinstance(span_processor, BatchSpanProcessor) + + exporter = span_processor.span_exporter + assert isinstance(exporter, OTLPSpanExporter) + assert exporter._endpoint == "https://my-collector.example.com/v1/traces" + assert exporter._headers is None or "X-Sentry-Auth" not in exporter._headers + + +def test_collector_url_takes_precedence_over_dsn(sentry_init): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[ + OTLPIntegration(collector_url="https://my-collector.example.com/v1/traces") + ], + ) + + tracer_provider = get_tracer_provider() + assert isinstance(tracer_provider, TracerProvider) + + (span_processor,) = tracer_provider._active_span_processor._span_processors + exporter = span_processor.span_exporter + assert isinstance(exporter, OTLPSpanExporter) + # Should use collector_url, NOT the DSN-derived endpoint + assert exporter._endpoint == "https://my-collector.example.com/v1/traces" + assert ( + exporter._endpoint + != "https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/" + ) + + +def test_collector_url_none_falls_back_to_dsn(sentry_init): + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + integrations=[OTLPIntegration(collector_url=None)], + ) + + tracer_provider = get_tracer_provider() + assert isinstance(tracer_provider, TracerProvider) + + (span_processor,) = tracer_provider._active_span_processor._span_processors + exporter = span_processor.span_exporter + assert isinstance(exporter, OTLPSpanExporter) + assert ( + exporter._endpoint + == "https://bla.ingest.sentry.io/api/12312012/integration/otlp/v1/traces/" + ) + assert "X-Sentry-Auth" in exporter._headers + + def test_capture_exceptions_enabled(sentry_init, capture_events): sentry_init( dsn="https://mysecret@bla.ingest.sentry.io/12312012",