Skip to content

Commit 10af785

Browse files
feat: add baggage span processor
1 parent 446db82 commit 10af785

File tree

5 files changed

+95
-58
lines changed

5 files changed

+95
-58
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"
@@ -17,6 +17,7 @@ dependencies = [
1717
"hatchling~=1.27.0",
1818
"opentelemetry-exporter-otlp-proto-grpc~=1.38.0",
1919
"opentelemetry-exporter-otlp-proto-http~=1.38.0",
20+
"opentelemetry-processor-baggage~=0.61b0",
2021
"traceloop-sdk~=0.52.0"
2122
]
2223

src/sap_cloud_sdk/core/telemetry/auto_instrument.py

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
88
OTLPSpanExporter as HTTPSpanExporter,
99
)
10-
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
10+
from opentelemetry import trace
11+
from opentelemetry.sdk.trace import TracerProvider
12+
from opentelemetry.processor.baggage import ALLOW_ALL_BAGGAGE_KEYS, BaggageSpanProcessor
13+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SpanExporter
1114
from traceloop.sdk import Traceloop
1215

1316
from sap_cloud_sdk.core.telemetry import Module, Operation
1417
from sap_cloud_sdk.core.telemetry.config import (
1518
create_resource_attributes_from_env,
1619
_get_app_name,
20+
ENV_OTLP_ENDPOINT,
21+
ENV_TRACES_EXPORTER,
22+
ENV_OTLP_PROTOCOL,
1723
)
1824
from sap_cloud_sdk.core.telemetry.genai_attribute_transformer import (
1925
GenAIAttributeTransformer,
@@ -31,36 +37,16 @@ def auto_instrument():
3137
Traces are exported to the OTEL collector endpoint configured in environment with
3238
OTEL_EXPORTER_OTLP_ENDPOINT, or printed to console when OTEL_TRACES_EXPORTER=console.
3339
"""
34-
otel_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
35-
console_traces = os.getenv("OTEL_TRACES_EXPORTER", "").lower() == "console"
40+
otel_endpoint = os.getenv(ENV_OTLP_ENDPOINT, "")
41+
console_traces = os.getenv(ENV_TRACES_EXPORTER, "").lower() == "console"
3642

3743
if not otel_endpoint and not console_traces:
3844
logger.warning(
3945
"OTEL_EXPORTER_OTLP_ENDPOINT not set. Instrumentation will be disabled."
4046
)
4147
return
4248

43-
if console_traces:
44-
logger.info("Initializing auto instrumentation with console exporter")
45-
base_exporter = ConsoleSpanExporter()
46-
else:
47-
if "v1/traces" not in otel_endpoint:
48-
otel_endpoint = otel_endpoint.rstrip("/") + "/v1/traces"
49-
protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower()
50-
exporters = {"grpc": GRPCSpanExporter, "http/protobuf": HTTPSpanExporter}
51-
if protocol not in exporters:
52-
raise ValueError(
53-
f"Unsupported OTEL_EXPORTER_OTLP_PROTOCOL: '{protocol}'. "
54-
"Supported values are 'grpc' and 'http/protobuf'."
55-
)
56-
57-
logger.info(
58-
f"Initializing auto instrumentation with endpoint: {otel_endpoint} "
59-
f"(protocol: {protocol})"
60-
)
61-
base_exporter = exporters[protocol](endpoint=otel_endpoint)
62-
63-
exporter = GenAIAttributeTransformer(base_exporter)
49+
exporter = GenAIAttributeTransformer(_create_exporter())
6450

6551
resource = create_resource_attributes_from_env()
6652
Traceloop.init(
@@ -71,4 +57,37 @@ def auto_instrument():
7157
disable_batch=True,
7258
)
7359

60+
_set_baggage_processor()
61+
7462
logger.info("Cloud auto instrumentation initialized successfully")
63+
64+
65+
def _create_exporter() -> SpanExporter:
66+
if os.getenv(ENV_TRACES_EXPORTER, "").lower() == "console":
67+
logger.info("Initializing auto instrumentation with console exporter")
68+
return ConsoleSpanExporter()
69+
70+
endpoint = os.getenv(ENV_OTLP_ENDPOINT, "")
71+
protocol = os.getenv(ENV_OTLP_PROTOCOL, "grpc").lower()
72+
exporters = {"grpc": GRPCSpanExporter, "http/protobuf": HTTPSpanExporter}
73+
if protocol not in exporters:
74+
raise ValueError(
75+
f"Unsupported OTEL_EXPORTER_OTLP_PROTOCOL: '{protocol}'. "
76+
"Supported values are 'grpc' and 'http/protobuf'."
77+
)
78+
79+
logger.info(
80+
f"Initializing auto instrumentation with endpoint: {endpoint} "
81+
f"(protocol: {protocol})"
82+
)
83+
return exporters[protocol]()
84+
85+
86+
def _set_baggage_processor():
87+
provider = trace.get_tracer_provider()
88+
if not isinstance(provider, TracerProvider):
89+
logger.warning("Unknown TracerProvider type. Skipping BaggageSpanProcessor")
90+
return
91+
92+
provider.add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
93+
logger.info("Registered BaggageSpanProcessor for extension attribute propagation")

src/sap_cloud_sdk/core/telemetry/config.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
ENV_HOSTNAME = "HOSTNAME"
3030
ENV_SYSTEM_ROLE = "APPFND_CONHOS_SYSTEM_ROLE"
3131

32+
# OTEL environment variable keys
33+
ENV_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
34+
ENV_TRACES_EXPORTER = "OTEL_TRACES_EXPORTER"
35+
ENV_OTLP_PROTOCOL = "OTEL_EXPORTER_OTLP_PROTOCOL"
36+
ENV_OTEL_DISABLED = "CLOUD_SDK_OTEL_DISABLED"
37+
3238

3339
def _get_region() -> str:
3440
"""Get region from environment or return default."""
@@ -134,13 +140,13 @@ def from_env(cls) -> "InstrumentationConfig":
134140
InstrumentationConfig instance with values from environment or defaults.
135141
"""
136142
# Get OTLP endpoint - if not set, telemetry is disabled
137-
otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
143+
otlp_endpoint = os.getenv(ENV_OTLP_ENDPOINT, "")
138144

139145
# Enable telemetry only if endpoint is configured
140146
# Can be explicitly disabled with CLOUD_SDK_OTEL_DISABLED=true
141147
enabled = (
142148
bool(otlp_endpoint)
143-
and os.getenv("CLOUD_SDK_OTEL_DISABLED", "false").lower() != "true"
149+
and os.getenv(ENV_OTEL_DISABLED, "false").lower() != "true"
144150
)
145151

146152
return cls(

tests/core/unit/telemetry/test_auto_instrument.py

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Tests for auto-instrumentation functionality."""
22

33
import pytest
4-
from unittest.mock import patch, MagicMock
4+
from unittest.mock import patch, MagicMock, create_autospec
55
from contextlib import ExitStack
66

7+
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
8+
79
from sap_cloud_sdk.core.telemetry.auto_instrument import auto_instrument
810

911

@@ -17,6 +19,8 @@ def mock_traceloop_components():
1719
'http_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.HTTPSpanExporter')),
1820
'console_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.ConsoleSpanExporter')),
1921
'transformer': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.GenAIAttributeTransformer')),
22+
'baggage_processor': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.BaggageSpanProcessor')),
23+
'get_tracer_provider': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=create_autospec(SDKTracerProvider))),
2024
'create_resource': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env')),
2125
'get_app_name': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name')),
2226
}
@@ -41,31 +45,16 @@ def test_auto_instrument_with_endpoint_success(self, mock_traceloop_components):
4145
assert call_kwargs['should_enrich_metrics'] is True
4246
assert call_kwargs['disable_batch'] is True
4347

44-
def test_auto_instrument_appends_v1_traces_to_endpoint(self, mock_traceloop_components):
45-
"""Test that auto_instrument appends /v1/traces to endpoint if not present."""
48+
def test_auto_instrument_uses_grpc_exporter_by_default(self, mock_traceloop_components):
49+
"""Test that auto_instrument uses gRPC exporter by default, letting it read endpoint from env."""
4650
mock_traceloop_components['get_app_name'].return_value = 'test-app'
4751
mock_traceloop_components['create_resource'].return_value = {}
4852

4953
with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True):
5054
auto_instrument()
5155

52-
# Verify exporter was called with /v1/traces appended (grpc by default)
53-
mock_traceloop_components['grpc_exporter'].assert_called_once_with(
54-
endpoint='http://localhost:4317/v1/traces'
55-
)
56-
57-
def test_auto_instrument_preserves_existing_v1_traces(self, mock_traceloop_components):
58-
"""Test that auto_instrument doesn't duplicate /v1/traces if already present."""
59-
mock_traceloop_components['get_app_name'].return_value = 'test-app'
60-
mock_traceloop_components['create_resource'].return_value = {}
61-
62-
with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317/v1/traces'}, clear=True):
63-
auto_instrument()
64-
65-
# Verify exporter was called with original endpoint (grpc by default)
66-
mock_traceloop_components['grpc_exporter'].assert_called_once_with(
67-
endpoint='http://localhost:4317/v1/traces'
68-
)
56+
mock_traceloop_components['grpc_exporter'].assert_called_once_with()
57+
mock_traceloop_components['http_exporter'].assert_not_called()
6958

7059
def test_auto_instrument_creates_resource_with_attributes(self, mock_traceloop_components):
7160
"""Test that auto_instrument creates resource with correct attributes."""
@@ -104,17 +93,14 @@ def test_auto_instrument_logs_initialization(self, mock_traceloop_components):
10493
assert any('initialized successfully' in msg.lower() for msg in info_calls)
10594

10695
def test_auto_instrument_with_trailing_slash(self, mock_traceloop_components):
107-
"""Test that auto_instrument handles endpoint with trailing slash."""
96+
"""Test that auto_instrument works with a trailing slash endpoint (exporter reads from env)."""
10897
mock_traceloop_components['get_app_name'].return_value = 'test-app'
10998
mock_traceloop_components['create_resource'].return_value = {}
11099

111100
with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317/'}, clear=True):
112101
auto_instrument()
113102

114-
# Verify trailing slash is removed before appending /v1/traces (grpc by default)
115-
mock_traceloop_components['grpc_exporter'].assert_called_once_with(
116-
endpoint='http://localhost:4317/v1/traces'
117-
)
103+
mock_traceloop_components['grpc_exporter'].assert_called_once_with()
118104

119105
def test_auto_instrument_with_http_protobuf_protocol(self, mock_traceloop_components):
120106
"""Test that auto_instrument uses HTTP exporter when OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf."""
@@ -127,11 +113,7 @@ def test_auto_instrument_with_http_protobuf_protocol(self, mock_traceloop_compon
127113
}, clear=True):
128114
auto_instrument()
129115

130-
# Verify HTTP exporter was called with /v1/traces appended
131-
mock_traceloop_components['http_exporter'].assert_called_once_with(
132-
endpoint='http://localhost:4318/v1/traces'
133-
)
134-
# Verify gRPC exporter was not called
116+
mock_traceloop_components['http_exporter'].assert_called_once_with()
135117
mock_traceloop_components['grpc_exporter'].assert_not_called()
136118

137119
def test_auto_instrument_passes_transformer_to_traceloop(self, mock_traceloop_components):
@@ -226,3 +208,16 @@ def test_auto_instrument_without_endpoint_or_console(self):
226208
mock_logger.warning.assert_called_once()
227209
warning_message = mock_logger.warning.call_args[0][0]
228210
assert "OTEL_EXPORTER_OTLP_ENDPOINT not set" in warning_message
211+
212+
def test_auto_instrument_passes_baggage_span_processor(self, mock_traceloop_components):
213+
"""Test that auto_instrument registers a BaggageSpanProcessor on the tracer provider."""
214+
mock_traceloop_components['get_app_name'].return_value = 'test-app'
215+
mock_traceloop_components['create_resource'].return_value = {}
216+
mock_processor_instance = MagicMock()
217+
mock_traceloop_components['baggage_processor'].return_value = mock_processor_instance
218+
219+
with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True):
220+
auto_instrument()
221+
222+
mock_traceloop_components['baggage_processor'].assert_called_once()
223+
mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.assert_called_once_with(mock_processor_instance)

uv.lock

Lines changed: 17 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)