From 980a53c95e7d28e5db737243429879b1852bfa04 Mon Sep 17 00:00:00 2001 From: Jean Scherf Date: Wed, 26 Aug 2026 11:08:28 -0300 Subject: [PATCH 1/2] fix(telemetry): suppress high-cardinality httpx spans for MCP calls cloud-sdk agents making hundreds of MCP calls (e.g. scoring-agent with 2,159 calls) produced monster traces of 60K+ spans because HTTPXClientInstrumentor creates a child span for every outbound httpx request regardless of cardinality. The span must still be created so that the W3C traceparent header is injected into the outgoing request before the transport runs. Excluded URLs in the upstream instrumentor skip header injection entirely, which breaks distributed trace context propagation downstream. Instead, a request hook marks high-cardinality spans with sap.cloud_sdk.suppress=True (sync and async), and GenAIAttributeTransformer filters them at export time. Traceparent propagates correctly; nothing is sent to the collector for these spans. URL patterns default to /v1/mcp/ and are overridable via SAP_CLOUD_SDK_HIGH_CARDINALITY_URLS (comma-separated substrings). --- src/sap_cloud_sdk/core/telemetry/config.py | 1 + .../telemetry/genai_attribute_transformer.py | 4 + .../instrumentation/instrumentors/httpx.py | 50 +- .../test_genai_attribute_transformer.py | 583 ++++++++++-------- uv.lock | 2 +- 5 files changed, 385 insertions(+), 255 deletions(-) diff --git a/src/sap_cloud_sdk/core/telemetry/config.py b/src/sap_cloud_sdk/core/telemetry/config.py index 45f3565d..caa3a6be 100644 --- a/src/sap_cloud_sdk/core/telemetry/config.py +++ b/src/sap_cloud_sdk/core/telemetry/config.py @@ -43,6 +43,7 @@ ENV_TRACES_EXPORTER = "OTEL_TRACES_EXPORTER" ENV_OTLP_PROTOCOL = "OTEL_EXPORTER_OTLP_PROTOCOL" ENV_OTEL_DISABLED = "CLOUD_SDK_OTEL_DISABLED" +ENV_HIGH_CARDINALITY_URLS = "SAP_CLOUD_SDK_HIGH_CARDINALITY_URLS" def _get_region() -> str: diff --git a/src/sap_cloud_sdk/core/telemetry/genai_attribute_transformer.py b/src/sap_cloud_sdk/core/telemetry/genai_attribute_transformer.py index 23ddec8a..14257882 100644 --- a/src/sap_cloud_sdk/core/telemetry/genai_attribute_transformer.py +++ b/src/sap_cloud_sdk/core/telemetry/genai_attribute_transformer.py @@ -53,6 +53,8 @@ def __init__(self, wrapped_exporter: SpanExporter): "GenAI attribute transformer initialized (minimal normalization enabled)" ) + _SUPPRESS_ATTR = "sap.cloud_sdk.suppress" + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: """ Export spans after transforming attributes. @@ -65,6 +67,8 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: """ transformed = [] for span in spans: + if span.attributes and span.attributes.get(self._SUPPRESS_ATTR): + continue try: transformed.append(self._normalize_attributes(span)) except Exception as e: diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py index 3b9ac7e7..f179c5e4 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py @@ -1,13 +1,56 @@ +import os +import re + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.trace import Span from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor from sap_cloud_sdk.core.telemetry.instrumentation._registry import register +from sap_cloud_sdk.core.telemetry.config import ENV_HIGH_CARDINALITY_URLS + +# Internal attribute that marks a span for suppression at export time. +# The span is still created so that W3C traceparent headers are injected +# into the outgoing request, preserving distributed trace context. +_SUPPRESS_ATTR = "sap.cloud_sdk.suppress" + +_DEFAULT_HIGH_CARDINALITY_PATTERNS = ["/v1/mcp/"] + + +def _compile_patterns() -> list[re.Pattern]: + raw = os.getenv(ENV_HIGH_CARDINALITY_URLS, "") + patterns = [p.strip() for p in raw.split(",") if p.strip()] + if not patterns: + patterns = _DEFAULT_HIGH_CARDINALITY_PATTERNS + return [re.compile(re.escape(p)) for p in patterns] + + +_patterns: list[re.Pattern] = _compile_patterns() + + +def _is_high_cardinality(url: str) -> bool: + return any(p.search(url) for p in _patterns) + + +def _request_hook(span: Span, request) -> None: + if _is_high_cardinality(str(request.url)): + span.set_attribute(_SUPPRESS_ATTR, True) + + +async def _async_request_hook(span: Span, request) -> None: + if _is_high_cardinality(str(request.url)): + span.set_attribute(_SUPPRESS_ATTR, True) + _instrumentor = HTTPXClientInstrumentor() class HttpxInstrumentor(LibraryInstrumentor): - """Instruments httpx sync and async clients with OTel spans and W3C header propagation.""" + """Instruments httpx sync and async clients with OTel spans and W3C header propagation. + + Spans for URLs matching SAP_CLOUD_SDK_HIGH_CARDINALITY_URLS (default: /v1/mcp/) are + marked for suppression at export time. The span is still created so that W3C traceparent + headers propagate to the downstream service. + """ library_name = "httpx" @@ -15,7 +58,10 @@ def is_instrumented(self) -> bool: return _instrumentor.is_instrumented_by_opentelemetry def _instrument(self, **kwargs) -> None: - _instrumentor.instrument() + _instrumentor.instrument( + request_hook=_request_hook, + async_request_hook=_async_request_hook, + ) def _uninstrument(self) -> None: _instrumentor.uninstrument() diff --git a/tests/core/unit/telemetry/test_genai_attribute_transformer.py b/tests/core/unit/telemetry/test_genai_attribute_transformer.py index 8ed310cd..72344757 100644 --- a/tests/core/unit/telemetry/test_genai_attribute_transformer.py +++ b/tests/core/unit/telemetry/test_genai_attribute_transformer.py @@ -7,7 +7,9 @@ from opentelemetry.sdk.trace.export import SpanExportResult -from sap_cloud_sdk.core.telemetry.genai_attribute_transformer import GenAIAttributeTransformer +from sap_cloud_sdk.core.telemetry.genai_attribute_transformer import ( + GenAIAttributeTransformer, +) def create_mock_span(attributes: dict, name: str = "test_span") -> MagicMock: @@ -39,158 +41,176 @@ def test_normalize_attributes_with_traceloop_model_name(self): mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'traceloop.association.properties.ls_provider': 'openai', - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "traceloop.association.properties.ls_provider": "openai", + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None - assert result.attributes['gen_ai.request.model'] == 'gpt-4' - assert result.attributes['gen_ai.provider.name'] == 'openai' + assert result.attributes["gen_ai.request.model"] == "gpt-4" + assert result.attributes["gen_ai.provider.name"] == "openai" # Fallback: response.model should be set from request.model - assert result.attributes['gen_ai.response.model'] == 'gpt-4' + assert result.attributes["gen_ai.response.model"] == "gpt-4" def test_normalize_attributes_preserves_existing_response_model(self): """Test normalization preserves existing gen_ai.response.model if present.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'gen_ai.response.model': 'gpt-4-turbo', # Already present, should not be overwritten - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "gen_ai.response.model": "gpt-4-turbo", # Already present, should not be overwritten + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None - assert result.attributes['gen_ai.request.model'] == 'gpt-4' + assert result.attributes["gen_ai.request.model"] == "gpt-4" # Should preserve the existing response.model - assert result.attributes['gen_ai.response.model'] == 'gpt-4-turbo' + assert result.attributes["gen_ai.response.model"] == "gpt-4-turbo" def test_normalize_attributes_replaces_unknown_response_model(self): """Test normalization replaces 'unknown' gen_ai.response.model with request model.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'gen_ai.response.model': 'unknown', # Should be replaced with actual model - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "gen_ai.response.model": "unknown", # Should be replaced with actual model + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None - assert result.attributes['gen_ai.request.model'] == 'gpt-4' + assert result.attributes["gen_ai.request.model"] == "gpt-4" # Should replace "unknown" with actual model name - assert result.attributes['gen_ai.response.model'] == 'gpt-4' + assert result.attributes["gen_ai.response.model"] == "gpt-4" def test_normalize_attributes_with_llm_usage(self): """Test normalization maps llm.usage.* to gen_ai.usage.*.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'llm.usage.total_tokens': 150, - 'llm.usage.input_tokens': 100, - 'llm.usage.output_tokens': 50, - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "llm.usage.total_tokens": 150, + "llm.usage.input_tokens": 100, + "llm.usage.output_tokens": 50, + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None - assert result.attributes['gen_ai.usage.total_tokens'] == 150 - assert result.attributes['gen_ai.usage.input_tokens'] == 100 - assert result.attributes['gen_ai.usage.output_tokens'] == 50 + assert result.attributes["gen_ai.usage.total_tokens"] == 150 + assert result.attributes["gen_ai.usage.input_tokens"] == 100 + assert result.attributes["gen_ai.usage.output_tokens"] == 50 def test_normalize_attributes_with_prompt_tokens_fallback(self): """Test normalization uses prompt_tokens as fallback for input_tokens.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'llm.usage.prompt_tokens': 100, - 'llm.usage.completion_tokens': 50, - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "llm.usage.prompt_tokens": 100, + "llm.usage.completion_tokens": 50, + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None - assert result.attributes['gen_ai.usage.input_tokens'] == 100 - assert result.attributes['gen_ai.usage.output_tokens'] == 50 + assert result.attributes["gen_ai.usage.input_tokens"] == 100 + assert result.attributes["gen_ai.usage.output_tokens"] == 50 def test_normalize_attributes_with_cache_read_tokens(self): """Test normalization includes cache read input tokens.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'llm.usage.cache_read_input_tokens': 25, - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "llm.usage.cache_read_input_tokens": 25, + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None - assert result.attributes['gen_ai.usage.cache_read_input_tokens'] == 25 + assert result.attributes["gen_ai.usage.cache_read_input_tokens"] == 25 def test_normalize_attributes_removes_standard_traceloop_attributes(self): """Test normalization removes only standard traceloop.* attributes, preserving custom ones.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'traceloop.association.properties.ls_provider': 'openai', - 'traceloop.custom.attribute': 'custom_value', - 'traceloop.proprietary.data': 'proprietary_value', - 'gen_ai.request.model': 'should-remain', - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "traceloop.association.properties.ls_provider": "openai", + "traceloop.custom.attribute": "custom_value", + "traceloop.proprietary.data": "proprietary_value", + "gen_ai.request.model": "should-remain", + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None # Standard traceloop.* attributes should be removed - assert 'traceloop.association.properties.ls_model_name' not in result.attributes - assert 'traceloop.association.properties.ls_provider' not in result.attributes + assert "traceloop.association.properties.ls_model_name" not in result.attributes + assert "traceloop.association.properties.ls_provider" not in result.attributes # Custom/proprietary traceloop.* attributes should be preserved - assert result.attributes.get('traceloop.custom.attribute') == 'custom_value' - assert result.attributes.get('traceloop.proprietary.data') == 'proprietary_value' + assert result.attributes.get("traceloop.custom.attribute") == "custom_value" + assert ( + result.attributes.get("traceloop.proprietary.data") == "proprietary_value" + ) # gen_ai attributes should remain - assert 'gen_ai.request.model' in result.attributes + assert "gen_ai.request.model" in result.attributes def test_normalize_attributes_removes_standard_llm_attributes(self): """Test normalization removes only standard llm.usage.* attributes, preserving custom ones.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'llm.usage.total_tokens': 150, - 'llm.usage.input_tokens': 100, - 'llm.custom_cost_tracking': 0.0023, - 'llm.proprietary.business_unit': 'finance', - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "llm.usage.total_tokens": 150, + "llm.usage.input_tokens": 100, + "llm.custom_cost_tracking": 0.0023, + "llm.proprietary.business_unit": "finance", + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None # Standard llm.usage.* attributes should be removed after transformation - assert 'llm.usage.total_tokens' not in result.attributes - assert 'llm.usage.input_tokens' not in result.attributes + assert "llm.usage.total_tokens" not in result.attributes + assert "llm.usage.input_tokens" not in result.attributes # Custom/proprietary llm.* attributes should be preserved - assert result.attributes.get('llm.custom_cost_tracking') == 0.0023 - assert result.attributes.get('llm.proprietary.business_unit') == 'finance' + assert result.attributes.get("llm.custom_cost_tracking") == 0.0023 + assert result.attributes.get("llm.proprietary.business_unit") == "finance" # gen_ai attributes should be present - assert 'gen_ai.usage.total_tokens' in result.attributes - assert 'gen_ai.usage.input_tokens' in result.attributes + assert "gen_ai.usage.total_tokens" in result.attributes + assert "gen_ai.usage.input_tokens" in result.attributes def test_normalize_attributes_skips_non_genai_spans(self): """Test normalization skips spans without traceloop or llm attributes.""" @@ -198,8 +218,8 @@ def test_normalize_attributes_skips_non_genai_spans(self): transformer = GenAIAttributeTransformer(mock_exporter) original_attrs = { - 'http.method': 'GET', - 'http.url': 'http://example.com', + "http.method": "GET", + "http.url": "http://example.com", } span = create_mock_span(original_attrs.copy()) @@ -225,7 +245,7 @@ def test_normalize_attributes_with_no_internal_attributes(self): transformer = GenAIAttributeTransformer(mock_exporter) span = MagicMock() - span.attributes = {'test': 'value'} + span.attributes = {"test": "value"} span._attributes = None result = transformer._normalize_attributes(span) @@ -236,34 +256,38 @@ def test_normalize_attributes_handles_non_string_values(self): mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 123, # non-string - 'traceloop.association.properties.ls_provider': None, - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": 123, # non-string + "traceloop.association.properties.ls_provider": None, + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None # Should not add gen_ai attributes for non-string values - assert 'gen_ai.request.model' not in result.attributes - assert 'gen_ai.provider.name' not in result.attributes + assert "gen_ai.request.model" not in result.attributes + assert "gen_ai.provider.name" not in result.attributes def test_normalize_attributes_handles_empty_string_values(self): """Test normalization handles empty string model name and provider.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': '', - 'traceloop.association.properties.ls_provider': '', - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "", + "traceloop.association.properties.ls_provider": "", + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None # Should not add gen_ai attributes for empty strings - assert 'gen_ai.request.model' not in result.attributes - assert 'gen_ai.provider.name' not in result.attributes + assert "gen_ai.request.model" not in result.attributes + assert "gen_ai.provider.name" not in result.attributes class TestMapLLMUsage: @@ -275,18 +299,18 @@ def test_map_llm_usage_all_fields(self): transformer = GenAIAttributeTransformer(mock_exporter) attrs = { - 'llm.usage.total_tokens': 150, - 'llm.usage.input_tokens': 100, - 'llm.usage.output_tokens': 50, - 'llm.usage.cache_read_input_tokens': 25, + "llm.usage.total_tokens": 150, + "llm.usage.input_tokens": 100, + "llm.usage.output_tokens": 50, + "llm.usage.cache_read_input_tokens": 25, } transformer._map_llm_usage(attrs) - assert attrs['gen_ai.usage.total_tokens'] == 150 - assert attrs['gen_ai.usage.input_tokens'] == 100 - assert attrs['gen_ai.usage.output_tokens'] == 50 - assert attrs['gen_ai.usage.cache_read_input_tokens'] == 25 + assert attrs["gen_ai.usage.total_tokens"] == 150 + assert attrs["gen_ai.usage.input_tokens"] == 100 + assert attrs["gen_ai.usage.output_tokens"] == 50 + assert attrs["gen_ai.usage.cache_read_input_tokens"] == 25 def test_map_llm_usage_prefers_input_tokens(self): """Test that input_tokens is preferred over prompt_tokens.""" @@ -294,13 +318,13 @@ def test_map_llm_usage_prefers_input_tokens(self): transformer = GenAIAttributeTransformer(mock_exporter) attrs = { - 'llm.usage.input_tokens': 100, - 'llm.usage.prompt_tokens': 200, # should be ignored + "llm.usage.input_tokens": 100, + "llm.usage.prompt_tokens": 200, # should be ignored } transformer._map_llm_usage(attrs) - assert attrs['gen_ai.usage.input_tokens'] == 100 + assert attrs["gen_ai.usage.input_tokens"] == 100 def test_map_llm_usage_prefers_output_tokens(self): """Test that output_tokens is preferred over completion_tokens.""" @@ -308,13 +332,13 @@ def test_map_llm_usage_prefers_output_tokens(self): transformer = GenAIAttributeTransformer(mock_exporter) attrs = { - 'llm.usage.output_tokens': 50, - 'llm.usage.completion_tokens': 100, # should be ignored + "llm.usage.output_tokens": 50, + "llm.usage.completion_tokens": 100, # should be ignored } transformer._map_llm_usage(attrs) - assert attrs['gen_ai.usage.output_tokens'] == 50 + assert attrs["gen_ai.usage.output_tokens"] == 50 def test_map_llm_usage_partial_fields(self): """Test mapping with only some fields present.""" @@ -322,14 +346,14 @@ def test_map_llm_usage_partial_fields(self): transformer = GenAIAttributeTransformer(mock_exporter) attrs = { - 'llm.usage.total_tokens': 150, + "llm.usage.total_tokens": 150, } transformer._map_llm_usage(attrs) - assert attrs['gen_ai.usage.total_tokens'] == 150 - assert 'gen_ai.usage.input_tokens' not in attrs - assert 'gen_ai.usage.output_tokens' not in attrs + assert attrs["gen_ai.usage.total_tokens"] == 150 + assert "gen_ai.usage.input_tokens" not in attrs + assert "gen_ai.usage.output_tokens" not in attrs class TestExport: @@ -341,10 +365,13 @@ def test_export_normalizes_genai_spans(self): mock_exporter.export.return_value = SpanExportResult.SUCCESS transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'llm.usage.total_tokens': 150, - }, name='chat') + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "llm.usage.total_tokens": 150, + }, + name="chat", + ) spans = [span] result = transformer.export(spans) @@ -352,10 +379,10 @@ def test_export_normalizes_genai_spans(self): # Verify transformation occurred on the span passed to the wrapped exporter exported = mock_exporter.export.call_args[0][0] exported_attrs = exported[0].attributes - assert 'gen_ai.request.model' in exported_attrs - assert 'gen_ai.usage.total_tokens' in exported_attrs - assert not any(k.startswith('llm.') for k in exported_attrs.keys()) - assert not any(k.startswith('traceloop.') for k in exported_attrs.keys()) + assert "gen_ai.request.model" in exported_attrs + assert "gen_ai.usage.total_tokens" in exported_attrs + assert not any(k.startswith("llm.") for k in exported_attrs.keys()) + assert not any(k.startswith("traceloop.") for k in exported_attrs.keys()) assert result == SpanExportResult.SUCCESS def test_export_non_genai_spans_unchanged(self): @@ -365,8 +392,8 @@ def test_export_non_genai_spans_unchanged(self): transformer = GenAIAttributeTransformer(mock_exporter) original_attrs = { - 'http.method': 'GET', - 'http.url': 'http://example.com', + "http.method": "GET", + "http.url": "http://example.com", } span = create_mock_span(original_attrs.copy()) spans = [span] @@ -386,10 +413,10 @@ def test_export_handles_transformation_error(self): # Create span that will cause error during transformation span = MagicMock() - span.attributes = {'traceloop.association.properties.ls_model_name': 'gpt-4'} - span.name = 'chat' + span.attributes = {"traceloop.association.properties.ls_model_name": "gpt-4"} + span.name = "chat" # Missing _attributes will cause error - delattr(span, '_attributes') + delattr(span, "_attributes") spans = [span] @@ -404,7 +431,7 @@ def test_export_calls_wrapped_exporter(self): mock_exporter.export.return_value = SpanExportResult.SUCCESS transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({'http.method': 'GET'}) + span = create_mock_span({"http.method": "GET"}) spans = [span] result = transformer.export(spans) @@ -412,6 +439,34 @@ def test_export_calls_wrapped_exporter(self): mock_exporter.export.assert_called_once() assert result == SpanExportResult.SUCCESS + def test_export_suppresses_marked_spans(self): + """Spans with sap.cloud_sdk.suppress=True are not forwarded to the wrapped exporter.""" + mock_exporter = MagicMock() + mock_exporter.export.return_value = SpanExportResult.SUCCESS + transformer = GenAIAttributeTransformer(mock_exporter) + + suppressed = create_mock_span( + {"sap.cloud_sdk.suppress": True, "http.url": "https://gw/v1/mcp/call"} + ) + normal = create_mock_span({"http.method": "GET"}) + + transformer.export([suppressed, normal]) + + exported = mock_exporter.export.call_args[0][0] + assert suppressed not in exported + assert normal in exported + + def test_export_suppressed_only_returns_success_with_empty_list(self): + """When all spans are suppressed, the wrapped exporter still receives an empty list.""" + mock_exporter = MagicMock() + mock_exporter.export.return_value = SpanExportResult.SUCCESS + transformer = GenAIAttributeTransformer(mock_exporter) + + suppressed = create_mock_span({"sap.cloud_sdk.suppress": True}) + transformer.export([suppressed]) + + mock_exporter.export.assert_called_once_with([]) + class TestShutdownAndForceFlush: """Test suite for shutdown and force_flush methods.""" @@ -466,120 +521,132 @@ def test_transform_input_messages(self): mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'gen_ai.prompt.0.role': 'user', - 'gen_ai.prompt.0.content': 'What is Application Foundation?', - 'gen_ai.prompt.1.role': 'system', - 'gen_ai.prompt.1.content': 'You are a helpful assistant', - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "gen_ai.prompt.0.role": "user", + "gen_ai.prompt.0.content": "What is Application Foundation?", + "gen_ai.prompt.1.role": "system", + "gen_ai.prompt.1.content": "You are a helpful assistant", + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None # Check that gen_ai.input.messages was created - assert 'gen_ai.input.messages' in result.attributes + assert "gen_ai.input.messages" in result.attributes # Parse and verify the JSON structure - messages = json.loads(str(result.attributes['gen_ai.input.messages'])) + messages = json.loads(str(result.attributes["gen_ai.input.messages"])) assert len(messages) == 2 - assert messages[0]['role'] == 'user' - assert messages[0]['parts'][0]['type'] == 'text' - assert messages[0]['parts'][0]['content'] == 'What is Application Foundation?' + assert messages[0]["role"] == "user" + assert messages[0]["parts"][0]["type"] == "text" + assert messages[0]["parts"][0]["content"] == "What is Application Foundation?" - assert messages[1]['role'] == 'system' - assert messages[1]['parts'][0]['type'] == 'text' - assert messages[1]['parts'][0]['content'] == 'You are a helpful assistant' + assert messages[1]["role"] == "system" + assert messages[1]["parts"][0]["type"] == "text" + assert messages[1]["parts"][0]["content"] == "You are a helpful assistant" # Verify old attributes were removed - assert not any(k.startswith('gen_ai.prompt.') for k in result.attributes.keys()) + assert not any(k.startswith("gen_ai.prompt.") for k in result.attributes.keys()) def test_transform_output_messages(self): """Test transforming gen_ai.completion.* to gen_ai.output.messages.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'gen_ai.completion.0.role': 'assistant', - 'gen_ai.completion.0.content': 'Application Foundation is...', - 'gen_ai.completion.0.finish_reason': 'stop', - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "gen_ai.completion.0.role": "assistant", + "gen_ai.completion.0.content": "Application Foundation is...", + "gen_ai.completion.0.finish_reason": "stop", + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None # Check that gen_ai.output.messages was created - assert 'gen_ai.output.messages' in result.attributes + assert "gen_ai.output.messages" in result.attributes # Parse and verify the JSON structure - messages = json.loads(str(result.attributes['gen_ai.output.messages'])) + messages = json.loads(str(result.attributes["gen_ai.output.messages"])) assert len(messages) == 1 - assert messages[0]['role'] == 'assistant' - assert messages[0]['parts'][0]['type'] == 'text' - assert messages[0]['parts'][0]['content'] == 'Application Foundation is...' - assert messages[0]['finish_reason'] == 'stop' + assert messages[0]["role"] == "assistant" + assert messages[0]["parts"][0]["type"] == "text" + assert messages[0]["parts"][0]["content"] == "Application Foundation is..." + assert messages[0]["finish_reason"] == "stop" # Verify old attributes were removed - assert not any(k.startswith('gen_ai.completion.') for k in result.attributes.keys()) + assert not any( + k.startswith("gen_ai.completion.") for k in result.attributes.keys() + ) def test_transform_multiple_output_messages(self): """Test transforming multiple completion messages.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'gen_ai.completion.0.role': 'assistant', - 'gen_ai.completion.0.content': 'First response', - 'gen_ai.completion.1.role': 'assistant', - 'gen_ai.completion.1.content': 'Second response', - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "gen_ai.completion.0.role": "assistant", + "gen_ai.completion.0.content": "First response", + "gen_ai.completion.1.role": "assistant", + "gen_ai.completion.1.content": "Second response", + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None - messages = json.loads(str(result.attributes['gen_ai.output.messages'])) + messages = json.loads(str(result.attributes["gen_ai.output.messages"])) assert len(messages) == 2 - assert messages[0]['parts'][0]['content'] == 'First response' - assert messages[1]['parts'][0]['content'] == 'Second response' + assert messages[0]["parts"][0]["content"] == "First response" + assert messages[1]["parts"][0]["content"] == "Second response" def test_transform_no_messages(self): """Test that transformation works when no message attributes present.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'llm.usage.total_tokens': 150, - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "llm.usage.total_tokens": 150, + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None # Should not create message attributes - assert 'gen_ai.input.messages' not in result.attributes - assert 'gen_ai.output.messages' not in result.attributes + assert "gen_ai.input.messages" not in result.attributes + assert "gen_ai.output.messages" not in result.attributes def test_transform_messages_with_extra_fields(self): """Test that extra fields in messages are preserved.""" mock_exporter = MagicMock() transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'gen_ai.prompt.0.role': 'user', - 'gen_ai.prompt.0.content': 'Hello', - 'gen_ai.prompt.0.custom_field': 'custom_value', - }) + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "gen_ai.prompt.0.role": "user", + "gen_ai.prompt.0.content": "Hello", + "gen_ai.prompt.0.custom_field": "custom_value", + } + ) result = transformer._normalize_attributes(span) assert result.attributes is not None - messages = json.loads(str(result.attributes['gen_ai.input.messages'])) - assert messages[0]['custom_field'] == 'custom_value' + messages = json.loads(str(result.attributes["gen_ai.input.messages"])) + assert messages[0]["custom_field"] == "custom_value" class TestCollectIndexedAttributes: @@ -591,17 +658,17 @@ def test_collect_indexed_attributes_basic(self): transformer = GenAIAttributeTransformer(mock_exporter) attrs = { - 'gen_ai.prompt.0.role': 'user', - 'gen_ai.prompt.0.content': 'Hello', - 'gen_ai.prompt.1.role': 'assistant', - 'gen_ai.prompt.1.content': 'Hi', + "gen_ai.prompt.0.role": "user", + "gen_ai.prompt.0.content": "Hello", + "gen_ai.prompt.1.role": "assistant", + "gen_ai.prompt.1.content": "Hi", } - result = transformer._collect_indexed_attributes(attrs, 'gen_ai.prompt.') + result = transformer._collect_indexed_attributes(attrs, "gen_ai.prompt.") assert len(result) == 2 - assert result[0] == {'role': 'user', 'content': 'Hello'} - assert result[1] == {'role': 'assistant', 'content': 'Hi'} + assert result[0] == {"role": "user", "content": "Hello"} + assert result[1] == {"role": "assistant", "content": "Hi"} def test_collect_indexed_attributes_empty(self): """Test collection with no matching attributes.""" @@ -609,11 +676,11 @@ def test_collect_indexed_attributes_empty(self): transformer = GenAIAttributeTransformer(mock_exporter) attrs = { - 'http.method': 'GET', - 'http.url': 'http://example.com', + "http.method": "GET", + "http.url": "http://example.com", } - result = transformer._collect_indexed_attributes(attrs, 'gen_ai.prompt.') + result = transformer._collect_indexed_attributes(attrs, "gen_ai.prompt.") assert len(result) == 0 @@ -623,12 +690,12 @@ def test_collect_indexed_attributes_invalid_format(self): transformer = GenAIAttributeTransformer(mock_exporter) attrs = { - 'gen_ai.prompt.notanumber.role': 'user', - 'gen_ai.prompt.0': 'missing_field', - 'gen_ai.prompt.': 'no_index', + "gen_ai.prompt.notanumber.role": "user", + "gen_ai.prompt.0": "missing_field", + "gen_ai.prompt.": "no_index", } - result = transformer._collect_indexed_attributes(attrs, 'gen_ai.prompt.') + result = transformer._collect_indexed_attributes(attrs, "gen_ai.prompt.") # Should skip invalid formats assert len(result) == 0 @@ -643,16 +710,16 @@ def test_structure_messages_basic(self): transformer = GenAIAttributeTransformer(mock_exporter) indexed_messages = { - 0: {'role': 'user', 'content': 'Hello'}, - 1: {'role': 'assistant', 'content': 'Hi'}, + 0: {"role": "user", "content": "Hello"}, + 1: {"role": "assistant", "content": "Hi"}, } result = transformer._structure_messages(indexed_messages) assert len(result) == 2 - assert result[0]['role'] == 'user' - assert result[0]['parts'][0]['type'] == 'text' - assert result[0]['parts'][0]['content'] == 'Hello' + assert result[0]["role"] == "user" + assert result[0]["parts"][0]["type"] == "text" + assert result[0]["parts"][0]["content"] == "Hello" def test_structure_messages_with_finish_reason(self): """Test structuring messages with finish_reason.""" @@ -660,12 +727,12 @@ def test_structure_messages_with_finish_reason(self): transformer = GenAIAttributeTransformer(mock_exporter) indexed_messages = { - 0: {'role': 'assistant', 'content': 'Response', 'finish_reason': 'stop'}, + 0: {"role": "assistant", "content": "Response", "finish_reason": "stop"}, } result = transformer._structure_messages(indexed_messages) - assert result[0]['finish_reason'] == 'stop' + assert result[0]["finish_reason"] == "stop" def test_structure_messages_missing_content(self): """Test structuring messages without content field.""" @@ -673,13 +740,13 @@ def test_structure_messages_missing_content(self): transformer = GenAIAttributeTransformer(mock_exporter) indexed_messages = { - 0: {'role': 'user'}, + 0: {"role": "user"}, } result = transformer._structure_messages(indexed_messages) - assert result[0]['role'] == 'user' - assert len(result[0]['parts']) == 0 + assert result[0]["role"] == "user" + assert len(result[0]["parts"]) == 0 def test_structure_messages_default_role(self): """Test structuring messages without role uses default.""" @@ -687,12 +754,12 @@ def test_structure_messages_default_role(self): transformer = GenAIAttributeTransformer(mock_exporter) indexed_messages = { - 0: {'content': 'Hello'}, + 0: {"content": "Hello"}, } result = transformer._structure_messages(indexed_messages) - assert result[0]['role'] == 'user' + assert result[0]["role"] == "user" def test_structure_messages_preserves_order(self): """Test that messages are ordered by index.""" @@ -700,17 +767,17 @@ def test_structure_messages_preserves_order(self): transformer = GenAIAttributeTransformer(mock_exporter) indexed_messages = { - 2: {'role': 'system', 'content': 'Third'}, - 0: {'role': 'user', 'content': 'First'}, - 1: {'role': 'assistant', 'content': 'Second'}, + 2: {"role": "system", "content": "Third"}, + 0: {"role": "user", "content": "First"}, + 1: {"role": "assistant", "content": "Second"}, } result = transformer._structure_messages(indexed_messages) assert len(result) == 3 - assert result[0]['parts'][0]['content'] == 'First' - assert result[1]['parts'][0]['content'] == 'Second' - assert result[2]['parts'][0]['content'] == 'Third' + assert result[0]["parts"][0]["content"] == "First" + assert result[1]["parts"][0]["content"] == "Second" + assert result[2]["parts"][0]["content"] == "Third" class TestIntegrationScenarios: @@ -722,36 +789,39 @@ def test_full_transformation_with_traceloop_preserves_custom(self): mock_exporter.export.return_value = SpanExportResult.SUCCESS transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'traceloop.association.properties.ls_provider': 'openai', - 'llm.usage.total_tokens': 150, - 'llm.usage.input_tokens': 100, - 'llm.usage.output_tokens': 50, - 'llm.custom_cost_tracking': 0.0023, - 'traceloop.custom_session_id': 'session-123', - }, name='ChatCompletion') + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "traceloop.association.properties.ls_provider": "openai", + "llm.usage.total_tokens": 150, + "llm.usage.input_tokens": 100, + "llm.usage.output_tokens": 50, + "llm.custom_cost_tracking": 0.0023, + "traceloop.custom_session_id": "session-123", + }, + name="ChatCompletion", + ) transformer.export([span]) # Verify all transformations on the exported span exported = mock_exporter.export.call_args[0][0] attrs = exported[0].attributes - assert attrs['gen_ai.request.model'] == 'gpt-4' - assert attrs['gen_ai.provider.name'] == 'openai' - assert attrs['gen_ai.usage.total_tokens'] == 150 - assert attrs['gen_ai.usage.input_tokens'] == 100 - assert attrs['gen_ai.usage.output_tokens'] == 50 + assert attrs["gen_ai.request.model"] == "gpt-4" + assert attrs["gen_ai.provider.name"] == "openai" + assert attrs["gen_ai.usage.total_tokens"] == 150 + assert attrs["gen_ai.usage.input_tokens"] == 100 + assert attrs["gen_ai.usage.output_tokens"] == 50 # Verify standard llm.usage.* and traceloop.association.* attributes were removed - assert 'llm.usage.total_tokens' not in attrs - assert 'llm.usage.input_tokens' not in attrs - assert 'traceloop.association.properties.ls_model_name' not in attrs - assert 'traceloop.association.properties.ls_provider' not in attrs + assert "llm.usage.total_tokens" not in attrs + assert "llm.usage.input_tokens" not in attrs + assert "traceloop.association.properties.ls_model_name" not in attrs + assert "traceloop.association.properties.ls_provider" not in attrs # Verify custom attributes were preserved - assert attrs.get('llm.custom_cost_tracking') == 0.0023 - assert attrs.get('traceloop.custom_session_id') == 'session-123' + assert attrs.get("llm.custom_cost_tracking") == 0.0023 + assert attrs.get("traceloop.custom_session_id") == "session-123" def test_full_transformation_with_messages(self): """Test complete transformation including message conversion.""" @@ -759,16 +829,19 @@ def test_full_transformation_with_messages(self): mock_exporter.export.return_value = SpanExportResult.SUCCESS transformer = GenAIAttributeTransformer(mock_exporter) - span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'traceloop.association.properties.ls_provider': 'openai', - 'gen_ai.prompt.0.role': 'user', - 'gen_ai.prompt.0.content': 'What is AI?', - 'gen_ai.completion.0.role': 'assistant', - 'gen_ai.completion.0.content': 'AI is...', - 'gen_ai.completion.0.finish_reason': 'stop', - 'llm.usage.total_tokens': 150, - }, name='ChatCompletion') + span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "traceloop.association.properties.ls_provider": "openai", + "gen_ai.prompt.0.role": "user", + "gen_ai.prompt.0.content": "What is AI?", + "gen_ai.completion.0.role": "assistant", + "gen_ai.completion.0.content": "AI is...", + "gen_ai.completion.0.finish_reason": "stop", + "llm.usage.total_tokens": 150, + }, + name="ChatCompletion", + ) transformer.export([span]) @@ -776,27 +849,27 @@ def test_full_transformation_with_messages(self): attrs = exported[0].attributes # Verify basic transformations - assert attrs['gen_ai.request.model'] == 'gpt-4' - assert attrs['gen_ai.provider.name'] == 'openai' + assert attrs["gen_ai.request.model"] == "gpt-4" + assert attrs["gen_ai.provider.name"] == "openai" # Verify message transformations - assert 'gen_ai.input.messages' in attrs - assert 'gen_ai.output.messages' in attrs + assert "gen_ai.input.messages" in attrs + assert "gen_ai.output.messages" in attrs - input_messages = json.loads(attrs['gen_ai.input.messages']) + input_messages = json.loads(attrs["gen_ai.input.messages"]) assert len(input_messages) == 1 - assert input_messages[0]['role'] == 'user' + assert input_messages[0]["role"] == "user" - output_messages = json.loads(attrs['gen_ai.output.messages']) + output_messages = json.loads(attrs["gen_ai.output.messages"]) assert len(output_messages) == 1 - assert output_messages[0]['role'] == 'assistant' - assert output_messages[0]['finish_reason'] == 'stop' + assert output_messages[0]["role"] == "assistant" + assert output_messages[0]["finish_reason"] == "stop" # Verify all old attributes were removed - assert not any(key.startswith('llm.') for key in attrs.keys()) - assert not any(key.startswith('traceloop.') for key in attrs.keys()) - assert not any(key.startswith('gen_ai.prompt.') for key in attrs.keys()) - assert not any(key.startswith('gen_ai.completion.') for key in attrs.keys()) + assert not any(key.startswith("llm.") for key in attrs.keys()) + assert not any(key.startswith("traceloop.") for key in attrs.keys()) + assert not any(key.startswith("gen_ai.prompt.") for key in attrs.keys()) + assert not any(key.startswith("gen_ai.completion.") for key in attrs.keys()) def test_mixed_genai_and_non_genai_spans(self): """Test exporting mix of GenAI and non-GenAI spans.""" @@ -804,14 +877,20 @@ def test_mixed_genai_and_non_genai_spans(self): mock_exporter.export.return_value = SpanExportResult.SUCCESS transformer = GenAIAttributeTransformer(mock_exporter) - genai_span = create_mock_span({ - 'traceloop.association.properties.ls_model_name': 'gpt-4', - 'llm.usage.total_tokens': 150, - }, name='chat') + genai_span = create_mock_span( + { + "traceloop.association.properties.ls_model_name": "gpt-4", + "llm.usage.total_tokens": 150, + }, + name="chat", + ) - http_span = create_mock_span({ - 'http.method': 'GET', - }, name='http_request') + http_span = create_mock_span( + { + "http.method": "GET", + }, + name="http_request", + ) transformer.export([genai_span, http_span]) @@ -820,10 +899,10 @@ def test_mixed_genai_and_non_genai_spans(self): exported_http = exported[1] # returned as-is (same object) # GenAI span should be transformed - assert 'gen_ai.request.model' in exported_genai - assert not any(k.startswith('llm.') for k in exported_genai.keys()) + assert "gen_ai.request.model" in exported_genai + assert not any(k.startswith("llm.") for k in exported_genai.keys()) # HTTP span should be unchanged (same object returned) assert exported_http is http_span - assert 'http.method' in exported_http.attributes - assert 'gen_ai.request.model' not in exported_http.attributes + assert "http.method" in exported_http.attributes + assert "gen_ai.request.model" not in exported_http.attributes diff --git a/uv.lock b/uv.lock index 2c98946e..d6874634 100644 --- a/uv.lock +++ b/uv.lock @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.0" +version = "0.45.3" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 8c9f997dd0f4371982eb69661ea8770c62ff9865 Mon Sep 17 00:00:00 2001 From: Jean Scherf Date: Wed, 26 Aug 2026 11:28:48 -0300 Subject: [PATCH 2/2] fix(telemetry): make high-cardinality URL suppression opt-in Remove /v1/mcp/ as the default pattern. By default no spans are suppressed so MCP call visibility is preserved for all agents. Deploy SAP_CLOUD_SDK_HIGH_CARDINALITY_URLS=pattern1,pattern2 to opt specific agents or workloads into suppression. --- .../telemetry/instrumentation/instrumentors/httpx.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py index f179c5e4..aa2a4203 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py @@ -13,14 +13,10 @@ # into the outgoing request, preserving distributed trace context. _SUPPRESS_ATTR = "sap.cloud_sdk.suppress" -_DEFAULT_HIGH_CARDINALITY_PATTERNS = ["/v1/mcp/"] - def _compile_patterns() -> list[re.Pattern]: raw = os.getenv(ENV_HIGH_CARDINALITY_URLS, "") patterns = [p.strip() for p in raw.split(",") if p.strip()] - if not patterns: - patterns = _DEFAULT_HIGH_CARDINALITY_PATTERNS return [re.compile(re.escape(p)) for p in patterns] @@ -47,9 +43,9 @@ async def _async_request_hook(span: Span, request) -> None: class HttpxInstrumentor(LibraryInstrumentor): """Instruments httpx sync and async clients with OTel spans and W3C header propagation. - Spans for URLs matching SAP_CLOUD_SDK_HIGH_CARDINALITY_URLS (default: /v1/mcp/) are - marked for suppression at export time. The span is still created so that W3C traceparent - headers propagate to the downstream service. + Spans for URLs matching SAP_CLOUD_SDK_HIGH_CARDINALITY_URLS (comma-separated substrings, + unset by default) are marked for suppression at export time. The span is still created so + that W3C traceparent headers propagate to the downstream service. """ library_name = "httpx"