Skip to content

Commit 5550854

Browse files
authored
Merge branch 'main' into feat/add-implicit-auditlog-on-agw
2 parents 02dc447 + c232dc0 commit 5550854

8 files changed

Lines changed: 280 additions & 37 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ dependencies = [
1717
"hatchling~=1.27.0",
1818
"opentelemetry-exporter-otlp-proto-grpc~=1.42.1",
1919
"opentelemetry-exporter-otlp-proto-http~=1.42.1",
20-
"opentelemetry-processor-baggage~=0.61b0",
2120
"traceloop-sdk~=0.61.0",
2221
"opentelemetry-instrumentation-langchain>=0.61.0",
2322
"httpx>=0.27.0",

src/sap_cloud_sdk/core/telemetry/auto_instrument.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
1212
OTLPSpanExporter as HTTPSpanExporter,
1313
)
14-
from opentelemetry.processor.baggage import ALLOW_ALL_BAGGAGE_KEYS, BaggageSpanProcessor
14+
from sap_cloud_sdk.core.telemetry.span_processors.baggage_span_processor import (
15+
ALLOW_ALL_BAGGAGE_KEYS,
16+
BaggageSpanProcessor,
17+
)
1518
from opentelemetry.sdk.resources import Resource
1619
from opentelemetry.sdk.trace import TracerProvider
1720
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SpanExporter
@@ -30,7 +33,7 @@
3033
GenAIAttributeTransformer,
3134
)
3235
from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics
33-
from sap_cloud_sdk.core.telemetry.propagated_attributes_processor import (
36+
from sap_cloud_sdk.core.telemetry.span_processors.propagated_attributes_processor import (
3437
PropagatedAttributesSpanProcessor,
3538
)
3639

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import logging
2+
from collections.abc import MutableMapping
3+
from typing import Any, Callable, Dict, Optional, cast
4+
5+
from opentelemetry.baggage import get_all as get_all_baggage
6+
from opentelemetry.context import Context
7+
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor
8+
from opentelemetry.trace import Span
9+
from opentelemetry.util.types import AttributeValue
10+
11+
logger = logging.getLogger(__name__)
12+
13+
BaggageKeyPredicateT = Callable[[str], bool]
14+
ALLOW_ALL_BAGGAGE_KEYS: BaggageKeyPredicateT = lambda _: True # noqa: E731
15+
16+
# Baggage keys where the caller-supplied value must win over any value set by
17+
# framework callbacks after on_start
18+
_DEFAULT_OVERRIDE_KEYS: tuple[str, ...] = ("gen_ai.conversation.id",)
19+
20+
21+
class BaggageSpanProcessor(SpanProcessor):
22+
"""Copies W3C baggage entries onto every span and enforces override keys.
23+
24+
On ``on_start``: iterates all baggage entries, applies those matching
25+
``baggage_key_predicate`` to the span via ``set_attribute``, and snapshots
26+
any ``override_keys`` values for re-application at end time.
27+
28+
On ``on_end``: re-writes the snapshot override values directly into
29+
``span._attributes``, ensuring they survive any framework ``set_attribute``
30+
calls made during the span's lifetime.
31+
32+
Args:
33+
baggage_key_predicate: Called for each baggage key; attribute is copied
34+
only when this returns ``True``. Use ``ALLOW_ALL_BAGGAGE_KEYS`` to
35+
copy everything.
36+
override_keys: Baggage keys whose value must always win over
37+
framework-injected span attributes. Only keys actually present in
38+
baggage at span start are tracked.
39+
"""
40+
41+
def __init__(
42+
self,
43+
baggage_key_predicate: BaggageKeyPredicateT,
44+
override_keys: tuple[str, ...] = _DEFAULT_OVERRIDE_KEYS,
45+
) -> None:
46+
self._baggage_key_predicate = baggage_key_predicate
47+
self._override_keys = frozenset(override_keys)
48+
self._pending: Dict[int, Dict[str, AttributeValue]] = {}
49+
50+
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
51+
if not span.is_recording():
52+
return
53+
all_baggage = get_all_baggage(parent_context)
54+
for key, value in all_baggage.items():
55+
if self._baggage_key_predicate(key):
56+
span.set_attribute(key, cast(AttributeValue, value))
57+
58+
overrides = {
59+
k: cast(AttributeValue, all_baggage[k])
60+
for k in self._override_keys
61+
if k in all_baggage
62+
}
63+
if overrides:
64+
span_id = getattr(getattr(span, "context", None), "span_id", None)
65+
if span_id is not None:
66+
self._pending[span_id] = overrides
67+
68+
def on_end(self, span: ReadableSpan) -> None:
69+
span_id = getattr(getattr(span, "context", None), "span_id", None)
70+
if span_id is None:
71+
return
72+
overrides = self._pending.pop(span_id, None)
73+
if not overrides:
74+
return
75+
if not hasattr(span, "_attributes") or span._attributes is None:
76+
return
77+
try:
78+
cast(MutableMapping[str, Any], span._attributes).update(overrides)
79+
except Exception as exc:
80+
logger.debug(
81+
"BaggageSpanProcessor: error enforcing overrides on span %r: %s",
82+
getattr(span, "name", "<unknown>"),
83+
exc,
84+
)
85+
86+
def shutdown(self) -> None:
87+
pass
88+
89+
def force_flush(self, timeout_millis: int = 30000) -> bool:
90+
return True

src/sap_cloud_sdk/core/telemetry/propagated_attributes_processor.py renamed to src/sap_cloud_sdk/core/telemetry/span_processors/propagated_attributes_processor.py

File renamed without changes.

src/sap_cloud_sdk/core/telemetry/user-guide.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,7 @@ if ext_ctx:
127127
The extension baggage span processor (registered automatically by `auto_instrument()`)
128128
stamps `sap.extension.*` attributes on all spans created inside an
129129
`extension_context()` block, including spans from third-party instrumentation.
130-
It uses the official `BaggageSpanProcessor` from `opentelemetry-processor-baggage`
131-
under the hood, filtering to only propagate `sap.extension.*` baggage keys.
130+
It uses a built-in `BaggageSpanProcessor` under the hood to stamp baggage keys.
132131

133132
### Available operations
134133

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Tests for BaggageSpanProcessor."""
2+
3+
from typing import Any, Dict
4+
from unittest.mock import MagicMock
5+
6+
from opentelemetry import baggage, context
7+
8+
from sap_cloud_sdk.core.telemetry.span_processors.baggage_span_processor import (
9+
ALLOW_ALL_BAGGAGE_KEYS,
10+
BaggageSpanProcessor,
11+
)
12+
13+
14+
def _make_context_with_baggage(entries: Dict[str, Any]):
15+
ctx = context.get_current()
16+
for k, v in entries.items():
17+
ctx = baggage.set_baggage(k, v, context=ctx)
18+
return ctx
19+
20+
21+
def _make_recording_span(span_id: int = 1):
22+
span = MagicMock()
23+
span.is_recording.return_value = True
24+
span.context.span_id = span_id
25+
return span
26+
27+
28+
def _make_ended_span(span_id: int = 1, attributes: Dict[str, Any] | None = None):
29+
span = MagicMock()
30+
span.context.span_id = span_id
31+
span._attributes = dict(attributes or {})
32+
return span
33+
34+
35+
class TestBaggageSpanProcessorOnStart:
36+
def setup_method(self):
37+
self.processor = BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS)
38+
39+
def test_copies_all_baggage_to_span(self):
40+
ctx = _make_context_with_baggage({"user.id": "u1", "session.id": "s1"})
41+
span = _make_recording_span()
42+
43+
self.processor.on_start(span, ctx)
44+
45+
span.set_attribute.assert_any_call("user.id", "u1")
46+
span.set_attribute.assert_any_call("session.id", "s1")
47+
48+
def test_predicate_filters_keys(self):
49+
ctx = _make_context_with_baggage({"keep.this": "yes", "drop.this": "no"})
50+
span = _make_recording_span()
51+
processor = BaggageSpanProcessor(lambda k: k.startswith("keep"))
52+
53+
processor.on_start(span, ctx)
54+
55+
span.set_attribute.assert_called_once_with("keep.this", "yes")
56+
57+
def test_no_op_when_baggage_empty(self):
58+
span = _make_recording_span()
59+
self.processor.on_start(span, context.get_current())
60+
span.set_attribute.assert_not_called()
61+
62+
def test_no_op_for_non_recording_span(self):
63+
ctx = _make_context_with_baggage({"gen_ai.conversation.id": "abc"})
64+
span = MagicMock()
65+
span.is_recording.return_value = False
66+
67+
self.processor.on_start(span, ctx)
68+
69+
span.set_attribute.assert_not_called()
70+
assert not self.processor._pending
71+
72+
def test_snapshots_override_key_when_present_in_baggage(self):
73+
ctx = _make_context_with_baggage({"gen_ai.conversation.id": "caller-uuid"})
74+
span = _make_recording_span(span_id=42)
75+
76+
self.processor.on_start(span, ctx)
77+
78+
assert self.processor._pending[42] == {"gen_ai.conversation.id": "caller-uuid"}
79+
80+
def test_does_not_snapshot_when_override_key_absent(self):
81+
ctx = _make_context_with_baggage({"other.key": "value"})
82+
span = _make_recording_span(span_id=42)
83+
84+
self.processor.on_start(span, ctx)
85+
86+
assert 42 not in self.processor._pending
87+
88+
89+
class TestBaggageSpanProcessorOnEnd:
90+
def setup_method(self):
91+
self.processor = BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS)
92+
93+
def test_override_wins_after_framework_overwrites(self):
94+
ctx = _make_context_with_baggage({"gen_ai.conversation.id": "caller-uuid"})
95+
span = _make_recording_span(span_id=7)
96+
self.processor.on_start(span, ctx)
97+
98+
# simulate framework callback overwriting the value mid-span
99+
ended = _make_ended_span(
100+
span_id=7, attributes={"gen_ai.conversation.id": "langgraph-thread-id"}
101+
)
102+
self.processor.on_end(ended)
103+
104+
assert ended._attributes["gen_ai.conversation.id"] == "caller-uuid"
105+
106+
def test_pending_entry_cleaned_up_after_on_end(self):
107+
ctx = _make_context_with_baggage({"gen_ai.conversation.id": "caller-uuid"})
108+
span = _make_recording_span(span_id=7)
109+
self.processor.on_start(span, ctx)
110+
111+
ended = _make_ended_span(span_id=7)
112+
self.processor.on_end(ended)
113+
114+
assert 7 not in self.processor._pending
115+
116+
def test_on_end_no_op_when_key_not_in_baggage(self):
117+
ctx = _make_context_with_baggage({"other.key": "value"})
118+
span = _make_recording_span(span_id=5)
119+
self.processor.on_start(span, ctx)
120+
121+
ended = _make_ended_span(
122+
span_id=5, attributes={"gen_ai.conversation.id": "framework-set"}
123+
)
124+
self.processor.on_end(ended)
125+
126+
# no override was snapshotted so framework value is untouched
127+
assert ended._attributes["gen_ai.conversation.id"] == "framework-set"
128+
129+
def test_on_end_no_op_for_unknown_span_id(self):
130+
ended = _make_ended_span(
131+
span_id=99, attributes={"gen_ai.conversation.id": "framework-set"}
132+
)
133+
self.processor.on_end(ended) # no KeyError
134+
assert ended._attributes["gen_ai.conversation.id"] == "framework-set"
135+
136+
def test_on_end_no_op_when_attributes_missing(self):
137+
ctx = _make_context_with_baggage({"gen_ai.conversation.id": "caller-uuid"})
138+
span = _make_recording_span(span_id=3)
139+
self.processor.on_start(span, ctx)
140+
141+
ended = MagicMock()
142+
ended.context.span_id = 3
143+
del ended._attributes # simulate missing _attributes
144+
145+
self.processor.on_end(ended) # must not raise
146+
147+
def test_on_end_swallows_mutation_error(self):
148+
ctx = _make_context_with_baggage({"gen_ai.conversation.id": "caller-uuid"})
149+
span = _make_recording_span(span_id=8)
150+
self.processor.on_start(span, ctx)
151+
152+
ended = MagicMock()
153+
ended.context.span_id = 8
154+
ended._attributes = MagicMock()
155+
ended._attributes.update.side_effect = RuntimeError("immutable")
156+
157+
self.processor.on_end(ended) # must not raise
158+
159+
160+
class TestBaggageSpanProcessorCustomOverrideKeys:
161+
def test_custom_override_key_is_enforced(self):
162+
processor = BaggageSpanProcessor(
163+
ALLOW_ALL_BAGGAGE_KEYS, override_keys=("my.custom.key",)
164+
)
165+
ctx = _make_context_with_baggage({"my.custom.key": "baggage-val"})
166+
span = _make_recording_span(span_id=1)
167+
processor.on_start(span, ctx)
168+
169+
ended = _make_ended_span(
170+
span_id=1, attributes={"my.custom.key": "framework-val"}
171+
)
172+
processor.on_end(ended)
173+
174+
assert ended._attributes["my.custom.key"] == "baggage-val"
175+
176+
def test_empty_override_keys_skips_snapshot(self):
177+
processor = BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS, override_keys=())
178+
ctx = _make_context_with_baggage({"gen_ai.conversation.id": "caller-uuid"})
179+
span = _make_recording_span(span_id=1)
180+
processor.on_start(span, ctx)
181+
182+
assert not processor._pending

tests/core/unit/telemetry/test_propagated_attributes_processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77

8-
from sap_cloud_sdk.core.telemetry.propagated_attributes_processor import (
8+
from sap_cloud_sdk.core.telemetry.span_processors.propagated_attributes_processor import (
99
PropagatedAttributesSpanProcessor,
1010
)
1111
from sap_cloud_sdk.core.telemetry.telemetry import _propagated_attrs_var

0 commit comments

Comments
 (0)