From 68873382f8e06291f3d7ea1a3dc683830c97f789 Mon Sep 17 00:00:00 2001 From: beyondszine <2175509+beyondszine@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:05:02 +0100 Subject: [PATCH 1/2] feat(agent_engines): tee traces to OTEL_EXPORTER_OTLP_ENDPOINT Agent Engine tracing exports spans only to the managed telemetry endpoint and ignores the standard OTEL_EXPORTER_OTLP_ENDPOINT. Users who want their agent's traces in an existing OTLP pipeline have no additive option -- instrumentor_builder replaces the default export entirely. Register an additional BatchSpanProcessor when the standard OTLP endpoint variable is set, alongside (not replacing) the default export. Opt-in, standard OpenTelemetry configuration, no public API change. Failures on the extra exporter are isolated by BatchSpanProcessor. Relates to #6267. --- vertexai/agent_engines/templates/adk.py | 45 +++++++++++++++++++ .../reasoning_engines/templates/adk.py | 45 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/vertexai/agent_engines/templates/adk.py b/vertexai/agent_engines/templates/adk.py index 8d1b28b7e8..c37bd3bd76 100644 --- a/vertexai/agent_engines/templates/adk.py +++ b/vertexai/agent_engines/templates/adk.py @@ -450,6 +450,7 @@ def _warn_missing_dependency( opentelemetry.sdk.trace.SynchronousMultiSpanProcessor(), ) tracer_provider.add_span_processor(span_processor) + _maybe_add_configured_otlp_span_processor(tracer_provider) if enable_logging: try: @@ -509,6 +510,50 @@ def _warn_missing_dependency( return None +def _maybe_add_configured_otlp_span_processor( + tracer_provider: "TracerProvider", +) -> None: + """Tees spans to a user-configured OTLP endpoint, in addition to the default + Agent Engine telemetry export. + + Honors the standard OpenTelemetry variables so an existing OTLP pipeline + works without code changes: ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`` or + ``OTEL_EXPORTER_OTLP_ENDPOINT`` (``OTEL_EXPORTER_OTLP_HEADERS`` is read by the + exporter). Does nothing when neither endpoint variable is set. Purely + additive: the default export is left in place, and failures on this extra + processor are isolated by ``BatchSpanProcessor``. + + Args: + tracer_provider (TracerProvider): + The tracer provider to add the additional span processor to. + """ + import os + + if not ( + os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + ): + return + + try: + import opentelemetry.sdk.trace.export + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter, + ) + except (ImportError, AttributeError): + _warn( + "OTEL_EXPORTER_OTLP_ENDPOINT is set but" + " opentelemetry-exporter-otlp-proto-http is not installed;" + " skipping the additional OTLP span exporter." + ) + return + + # OTLPSpanExporter() reads OTEL_EXPORTER_OTLP_* (endpoint, headers, timeout). + tracer_provider.add_span_processor( + opentelemetry.sdk.trace.export.BatchSpanProcessor(OTLPSpanExporter()) + ) + + def _override_active_span_processor( tracer_provider: "TracerProvider", active_span_processor: "SynchronousMultiSpanProcessor", diff --git a/vertexai/preview/reasoning_engines/templates/adk.py b/vertexai/preview/reasoning_engines/templates/adk.py index 24b28caa18..b0f75242b2 100644 --- a/vertexai/preview/reasoning_engines/templates/adk.py +++ b/vertexai/preview/reasoning_engines/templates/adk.py @@ -684,6 +684,7 @@ def _warn_missing_dependency( opentelemetry.sdk.trace.SynchronousMultiSpanProcessor(), ) tracer_provider.add_span_processor(span_processor) + _maybe_add_configured_otlp_span_processor(tracer_provider) if enable_logging: try: @@ -743,6 +744,50 @@ def _warn_missing_dependency( return None +def _maybe_add_configured_otlp_span_processor( + tracer_provider: "TracerProvider", +) -> None: + """Tees spans to a user-configured OTLP endpoint, in addition to the default + Agent Engine telemetry export. + + Honors the standard OpenTelemetry variables so an existing OTLP pipeline + works without code changes: ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`` or + ``OTEL_EXPORTER_OTLP_ENDPOINT`` (``OTEL_EXPORTER_OTLP_HEADERS`` is read by the + exporter). Does nothing when neither endpoint variable is set. Purely + additive: the default export is left in place, and failures on this extra + processor are isolated by ``BatchSpanProcessor``. + + Args: + tracer_provider (TracerProvider): + The tracer provider to add the additional span processor to. + """ + import os + + if not ( + os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") + ): + return + + try: + import opentelemetry.sdk.trace.export + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter, + ) + except (ImportError, AttributeError): + _warn( + "OTEL_EXPORTER_OTLP_ENDPOINT is set but" + " opentelemetry-exporter-otlp-proto-http is not installed;" + " skipping the additional OTLP span exporter." + ) + return + + # OTLPSpanExporter() reads OTEL_EXPORTER_OTLP_* (endpoint, headers, timeout). + tracer_provider.add_span_processor( + opentelemetry.sdk.trace.export.BatchSpanProcessor(OTLPSpanExporter()) + ) + + def _override_active_span_processor( tracer_provider: "TracerProvider", active_span_processor: "SynchronousMultiSpanProcessor", From 6205cd069c209c75f6a8025cc615845f4325bc1e Mon Sep 17 00:00:00 2001 From: beyondszine Date: Thu, 15 Oct 2026 15:54:33 +0100 Subject: [PATCH 2/2] test(agent_engines): cover OTEL_EXPORTER_OTLP_ENDPOINT span processor tee --- .../test_agent_engine_templates_adk.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/unit/vertex_adk/test_agent_engine_templates_adk.py b/tests/unit/vertex_adk/test_agent_engine_templates_adk.py index ef5a2a29f2..3b4b1c1ef3 100644 --- a/tests/unit/vertex_adk/test_agent_engine_templates_adk.py +++ b/tests/unit/vertex_adk/test_agent_engine_templates_adk.py @@ -2359,3 +2359,85 @@ def test_the_record_handed_to_the_processor_is_not_mutated(self, monkeypatch): assert "gcp.resource_type" not in incoming.resource.attributes assert emitted.log_record.attributes["event.name"] == "gen_ai.choice" provider.shutdown() + + +class TestConfiguredOtlpSpanProcessor: + """Tests for the additive OTEL_EXPORTER_OTLP_ENDPOINT span processor.""" + + _EXPORTER_PATH = ( + "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter" + ) + + @staticmethod + def _tracer_provider(): + import opentelemetry.sdk.trace + + return opentelemetry.sdk.trace.TracerProvider() + + @staticmethod + def _processor_count(tracer_provider): + return len(tracer_provider._active_span_processor._span_processors) + + @mock.patch.dict(os.environ, {}, clear=False) + def test_no_op_when_endpoint_not_set(self): + os.environ.pop("OTEL_EXPORTER_OTLP_ENDPOINT", None) + os.environ.pop("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None) + tracer_provider = self._tracer_provider() + with mock.patch(self._EXPORTER_PATH) as mock_exporter: + adk_template._maybe_add_configured_otlp_span_processor(tracer_provider) + mock_exporter.assert_not_called() + assert self._processor_count(tracer_provider) == 0 + + @pytest.mark.parametrize( + "env_var", + ["OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"], + ) + def test_adds_batch_span_processor_when_endpoint_set(self, env_var): + import opentelemetry.sdk.trace.export + + tracer_provider = self._tracer_provider() + with mock.patch.dict(os.environ, {env_var: "http://localhost:4318"}): + with mock.patch(self._EXPORTER_PATH) as mock_exporter: + adk_template._maybe_add_configured_otlp_span_processor( + tracer_provider + ) + # The exporter is built with no arguments so it reads OTEL_EXPORTER_OTLP_*. + mock_exporter.assert_called_once_with() + processors = tracer_provider._active_span_processor._span_processors + assert len(processors) == 1 + assert isinstance( + processors[0], opentelemetry.sdk.trace.export.BatchSpanProcessor + ) + tracer_provider.shutdown() + + def test_does_not_remove_existing_processors(self): + tracer_provider = self._tracer_provider() + existing = mock.MagicMock() + tracer_provider.add_span_processor(existing) + with mock.patch.dict( + os.environ, {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318"} + ): + with mock.patch(self._EXPORTER_PATH): + adk_template._maybe_add_configured_otlp_span_processor( + tracer_provider + ) + processors = tracer_provider._active_span_processor._span_processors + assert len(processors) == 2 + assert processors[0] is existing + tracer_provider.shutdown() + + def test_warns_and_skips_when_exporter_not_installed(self): + tracer_provider = self._tracer_provider() + with mock.patch.dict( + os.environ, {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318"} + ): + with mock.patch.dict( + sys.modules, + {"opentelemetry.exporter.otlp.proto.http.trace_exporter": None}, + ): + with mock.patch.object(adk_template, "_warn") as mock_warn: + adk_template._maybe_add_configured_otlp_span_processor( + tracer_provider + ) + mock_warn.assert_called_once() + assert self._processor_count(tracer_provider) == 0