Skip to content

Commit 4386612

Browse files
committed
fix(telemetry): inject invoke_agent identity via ContextVar and SpanProcessor
Replace Baggage-based mirroring for gh-55. Register InvokeAgentIdentitySpanProcessor in auto_instrument alongside BaggageSpanProcessor. Update tests and Jaeger verification script.
1 parent 1a97da8 commit 4386612

8 files changed

Lines changed: 111 additions & 42 deletions

File tree

scripts/verify_invoke_agent_jaeger_integration.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,14 @@ def _worker_main() -> int:
7878
"""Single-process OTLP emit + Jaeger assert (import OTEL only here)."""
7979
from opentelemetry import trace
8080
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
81-
from opentelemetry.processor.baggage import ALLOW_ALL_BAGGAGE_KEYS, BaggageSpanProcessor
8281
from opentelemetry.sdk.resources import Resource
8382
from opentelemetry.sdk.trace import TracerProvider
8483
from opentelemetry.sdk.trace.export import BatchSpanProcessor
8584
from opentelemetry.trace import SpanKind
8685

86+
from sap_cloud_sdk.core.telemetry.invoke_agent_identity_processor import (
87+
InvokeAgentIdentitySpanProcessor,
88+
)
8789
from sap_cloud_sdk.core.telemetry.tracer import invoke_agent_span
8890

8991
p = argparse.ArgumentParser()
@@ -102,7 +104,7 @@ def _worker_main() -> int:
102104
resource = Resource.create({"service.name": svc})
103105
exporter = OTLPSpanExporter(endpoint=_grpc_host_port(args.otlp_endpoint), insecure=True)
104106
provider = TracerProvider(resource=resource)
105-
provider.add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
107+
provider.add_span_processor(InvokeAgentIdentitySpanProcessor())
106108
provider.add_span_processor(BatchSpanProcessor(exporter))
107109
trace.set_tracer_provider(provider)
108110

src/sap_cloud_sdk/core/telemetry/auto_instrument.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
from sap_cloud_sdk.core.telemetry.genai_attribute_transformer import (
2525
GenAIAttributeTransformer,
2626
)
27+
from sap_cloud_sdk.core.telemetry.invoke_agent_identity_processor import (
28+
InvokeAgentIdentitySpanProcessor,
29+
)
2730
from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics
2831

2932
logger = logging.getLogger(__name__)
@@ -62,7 +65,7 @@ def auto_instrument(disable_batch: bool = False):
6265
disable_batch=disable_batch,
6366
)
6467

65-
_set_baggage_processor()
68+
_register_sdk_span_processors()
6669

6770
logger.info("Cloud auto instrumentation initialized successfully")
6871

@@ -88,11 +91,18 @@ def _create_exporter() -> SpanExporter:
8891
return exporters[protocol]()
8992

9093

91-
def _set_baggage_processor():
94+
def _register_sdk_span_processors():
9295
provider = trace.get_tracer_provider()
9396
if not isinstance(provider, TracerProvider):
94-
logger.warning("Unknown TracerProvider type. Skipping BaggageSpanProcessor")
97+
logger.warning(
98+
"Unknown TracerProvider type. Skipping BaggageSpanProcessor and "
99+
"InvokeAgentIdentitySpanProcessor"
100+
)
95101
return
96102

97103
provider.add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
98104
logger.info("Registered BaggageSpanProcessor for extension attribute propagation")
105+
provider.add_span_processor(InvokeAgentIdentitySpanProcessor())
106+
logger.info(
107+
"Registered InvokeAgentIdentitySpanProcessor for invoke_agent identity propagation"
108+
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""SpanProcessor that injects invoke_agent identity from a ContextVar into every span."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Optional
6+
7+
from opentelemetry.context import Context
8+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
9+
10+
from sap_cloud_sdk.core.telemetry.telemetry import get_invoke_agent_identity
11+
12+
13+
class InvokeAgentIdentitySpanProcessor(SpanProcessor):
14+
"""Apply ``gen_ai.agent.{name,id,description}`` stored in context to each started span.
15+
16+
Values come from :func:`~sap_cloud_sdk.core.telemetry.telemetry.get_invoke_agent_identity`,
17+
set only while ``invoke_agent_span(..., propagate=True)`` is active. This avoids using
18+
W3C Baggage for in-process-only identity (review feedback: baggage size / cross-trace concerns).
19+
"""
20+
21+
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
22+
identity = get_invoke_agent_identity()
23+
if not identity:
24+
return
25+
for key, value in identity.items():
26+
span.set_attribute(key, value)
27+
28+
def on_end(self, span: ReadableSpan) -> None:
29+
pass
30+
31+
def shutdown(self) -> None:
32+
pass
33+
34+
def force_flush(self, timeout_millis: float = 30000) -> bool:
35+
return True

src/sap_cloud_sdk/core/telemetry/telemetry.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@
3636
"propagated_attrs", default={}
3737
)
3838

39+
# In-process identity for invoke_agent_span(propagate=True) — injected via SpanProcessor
40+
_invoke_agent_identity_var: ContextVar[Optional[Dict[str, str]]] = ContextVar(
41+
"sap_cloud_sdk_invoke_agent_identity", default=None
42+
)
43+
44+
45+
def get_invoke_agent_identity() -> Optional[Dict[str, str]]:
46+
"""Return merged ``gen_ai.agent.*`` identity for the current invoke_agent propagation scope."""
47+
return _invoke_agent_identity_var.get()
48+
3949

4050
def set_tenant_id(tenant_id: str) -> None:
4151
"""Set the tenant ID for the current request context.

src/sap_cloud_sdk/core/telemetry/tracer.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
from contextlib import contextmanager, nullcontext
1010
from typing import Optional, Dict, Any
1111

12-
from opentelemetry import baggage as otel_baggage
13-
from opentelemetry import context as otel_context
1412
from opentelemetry import trace
1513
from opentelemetry.trace import Status, StatusCode, Span
1614

@@ -19,6 +17,7 @@
1917
get_tenant_id,
2018
get_propagated_attributes,
2119
_propagated_attrs_var,
20+
_invoke_agent_identity_var,
2221
)
2322
from sap_cloud_sdk.core.telemetry.constants import ATTR_SAP_TENANT_ID
2423

@@ -49,29 +48,30 @@ def _propagate_attributes(attrs: Dict[str, Any]):
4948

5049

5150
@contextmanager
52-
def _otel_baggage_for_invoke_agent(span_attrs: Dict[str, Any]):
53-
"""Mirror gen_ai.agent.* identity into W3C Baggage for the duration of the context.
51+
def _invoke_agent_identity_scope(span_attrs: Dict[str, Any]):
52+
"""Push gen_ai.agent.{name,id,description} onto a ContextVar for the duration of the context.
5453
55-
Third-party instrumentations (e.g. LangGraph / Traceloop) create spans without merging
56-
:func:`get_propagated_attributes`. :class:`~opentelemetry.processor.baggage.BaggageSpanProcessor`
57-
(registered by :func:`sap_cloud_sdk.core.telemetry.auto_instrument.auto_instrument`) copies
58-
baggage onto every started span, so agent id/name stay consistent on those spans.
54+
Third-party instrumentations create spans without merging :func:`get_propagated_attributes`.
55+
:class:`~sap_cloud_sdk.core.telemetry.invoke_agent_identity_processor.InvokeAgentIdentitySpanProcessor`
56+
(registered by :func:`sap_cloud_sdk.core.telemetry.auto_instrument.auto_instrument`) copies these
57+
values onto every started span while the scope is active, without using W3C Baggage.
5958
"""
6059
keys = (
6160
_ATTR_GEN_AI_AGENT_NAME,
6261
_ATTR_GEN_AI_AGENT_ID,
6362
_ATTR_GEN_AI_AGENT_DESCRIPTION,
6463
)
65-
ctx = otel_context.get_current()
66-
for key in keys:
67-
val = span_attrs.get(key)
68-
if val is not None:
69-
ctx = otel_baggage.set_baggage(key, str(val), ctx)
70-
token = otel_context.attach(ctx)
64+
patch_dict = {k: str(span_attrs[k]) for k in keys if span_attrs.get(k) is not None}
65+
if not patch_dict:
66+
yield
67+
return
68+
prev = _invoke_agent_identity_var.get()
69+
merged: Dict[str, str] = {**(prev or {}), **patch_dict}
70+
token = _invoke_agent_identity_var.set(merged)
7171
try:
7272
yield
7373
finally:
74-
otel_context.detach(token)
74+
_invoke_agent_identity_var.reset(token)
7575

7676

7777
@contextmanager
@@ -309,9 +309,10 @@ def invoke_agent_span(
309309
attributes: Optional dict of extra attributes to add or override on the span.
310310
propagate: If True, this span's attributes are passed to all nested spans
311311
within its scope as the lowest-priority layer. Additionally,
312-
``gen_ai.agent.{name,id,description}`` are attached as OpenTelemetry
313-
**Baggage** so spans created by external instrumentations still receive
314-
those attributes when BaggageSpanProcessor is active.
312+
``gen_ai.agent.{name,id,description}`` are stored in a ContextVar and
313+
copied onto every nested span by
314+
:class:`~sap_cloud_sdk.core.telemetry.invoke_agent_identity_processor.InvokeAgentIdentitySpanProcessor`
315+
when it is registered (e.g. via :func:`sap_cloud_sdk.core.telemetry.auto_instrument.auto_instrument`).
315316
316317
Yields:
317318
The created Span (e.g. to set usage, response attributes).
@@ -356,11 +357,11 @@ def invoke_agent_span(
356357
span_attrs = {**propagated, **(attributes or {}), **base_attrs}
357358

358359
ctx_prop = _propagate_attributes(span_attrs) if propagate else nullcontext()
359-
ctx_baggage = (
360-
_otel_baggage_for_invoke_agent(span_attrs) if propagate else nullcontext()
360+
ctx_identity = (
361+
_invoke_agent_identity_scope(span_attrs) if propagate else nullcontext()
361362
)
362363
with ctx_prop:
363-
with ctx_baggage:
364+
with ctx_identity:
364365
with tracer.start_as_current_span(
365366
span_name,
366367
kind=kind,

tests/core/unit/telemetry/test_auto_instrument.py

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

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

77
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider
@@ -20,6 +20,7 @@ def mock_traceloop_components():
2020
'console_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.ConsoleSpanExporter')),
2121
'transformer': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.GenAIAttributeTransformer')),
2222
'baggage_processor': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.BaggageSpanProcessor')),
23+
'identity_processor': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.InvokeAgentIdentitySpanProcessor')),
2324
'get_tracer_provider': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=create_autospec(SDKTracerProvider))),
2425
'create_resource': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env')),
2526
'get_app_name': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name')),
@@ -220,15 +221,21 @@ def test_auto_instrument_disable_batch_can_be_set_to_true(self, mock_traceloop_c
220221
call_kwargs = mock_traceloop_components['traceloop'].init.call_args[1]
221222
assert call_kwargs['disable_batch'] is True
222223

223-
def test_auto_instrument_passes_baggage_span_processor(self, mock_traceloop_components):
224-
"""Test that auto_instrument registers a BaggageSpanProcessor on the tracer provider."""
224+
def test_auto_instrument_registers_sdk_span_processors(self, mock_traceloop_components):
225+
"""Test that auto_instrument registers BaggageSpanProcessor and InvokeAgentIdentitySpanProcessor."""
225226
mock_traceloop_components['get_app_name'].return_value = 'test-app'
226227
mock_traceloop_components['create_resource'].return_value = {}
227-
mock_processor_instance = MagicMock()
228-
mock_traceloop_components['baggage_processor'].return_value = mock_processor_instance
228+
mock_baggage_instance = MagicMock()
229+
mock_identity_instance = MagicMock()
230+
mock_traceloop_components['baggage_processor'].return_value = mock_baggage_instance
231+
mock_traceloop_components['identity_processor'].return_value = mock_identity_instance
229232

230233
with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True):
231234
auto_instrument()
232235

233236
mock_traceloop_components['baggage_processor'].assert_called_once()
234-
mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.assert_called_once_with(mock_processor_instance)
237+
mock_traceloop_components['identity_processor'].assert_called_once()
238+
provider = mock_traceloop_components['get_tracer_provider'].return_value
239+
provider.add_span_processor.assert_has_calls(
240+
[call(mock_baggage_instance), call(mock_identity_instance)]
241+
)

tests/core/unit/telemetry/test_tracer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -874,7 +874,7 @@ def mock_start_as_current_span(name, kind=None, attributes=None):
874874
mock_span.add_event.assert_called_once_with("agent_step")
875875

876876
def test_invoke_agent_propagate_baggage_applies_to_external_tracer_span_subprocess(self):
877-
"""Regression SAP/cloud-sdk-python#55: third-party spans get gen_ai.agent.* via Baggage."""
877+
"""Regression SAP/cloud-sdk-python#55: third-party spans get gen_ai.agent.* via SpanProcessor."""
878878
import os
879879
import subprocess
880880
import sys
@@ -898,7 +898,7 @@ def test_invoke_agent_propagate_baggage_applies_to_external_tracer_span_subproce
898898
def test_invoke_agent_propagate_false_skips_baggage_for_external_tracer_span_subprocess(
899899
self,
900900
):
901-
"""Baggage mirroring for agent identity runs only when propagate=True."""
901+
"""Invoke-agent identity propagation runs only when propagate=True."""
902902
import os
903903
import subprocess
904904
import sys

tests/core/unit/telemetry/verify_invoke_agent_baggage.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
#!/usr/bin/env python3
2-
"""Subprocess helper: Baggage propagation on invoke_agent_span + external tracer.
2+
"""Subprocess helper: invoke_agent identity via ContextVar + SpanProcessor + external tracer.
33
44
Run from repo root with PYTHONPATH=src (see test_tracer.py).
55
66
Modes:
77
default — propagate=True expects gen_ai.agent.* on nested span
8-
--propagate-false — propagate=False expects nested span WITHOUT those attrs from Baggage
8+
--propagate-false — propagate=False expects nested span WITHOUT those attrs from identity injection
99
"""
1010

1111
from __future__ import annotations
@@ -21,12 +21,14 @@
2121
sys.path.insert(0, str(_SRC))
2222

2323
from opentelemetry import trace
24-
from opentelemetry.processor.baggage import ALLOW_ALL_BAGGAGE_KEYS, BaggageSpanProcessor
2524
from opentelemetry.sdk.trace import TracerProvider
2625
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
2726
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
2827
from opentelemetry.trace import SpanKind
2928

29+
from sap_cloud_sdk.core.telemetry.invoke_agent_identity_processor import (
30+
InvokeAgentIdentitySpanProcessor,
31+
)
3032
from sap_cloud_sdk.core.telemetry.tracer import invoke_agent_span
3133

3234

@@ -35,14 +37,14 @@ def main() -> int:
3537
parser.add_argument(
3638
"--propagate-false",
3739
action="store_true",
38-
help="Verify Baggage is NOT applied to external span when propagate=False",
40+
help="Verify identity injection is NOT applied when propagate=False",
3941
)
4042
args = parser.parse_args()
4143
propagate = not args.propagate_false
4244

4345
exporter = InMemorySpanExporter()
4446
provider = TracerProvider()
45-
provider.add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
47+
provider.add_span_processor(InvokeAgentIdentitySpanProcessor())
4648
provider.add_span_processor(SimpleSpanProcessor(exporter))
4749
trace.set_tracer_provider(provider)
4850

@@ -72,19 +74,21 @@ def main() -> int:
7274
print("FAIL: gen_ai.agent.id", inner, file=sys.stderr)
7375
return 1
7476
print(
75-
"OK: external child span carries gen_ai.agent.name and gen_ai.agent.id via baggage"
77+
"OK: external child span carries gen_ai.agent.name and gen_ai.agent.id via SpanProcessor"
7678
)
7779
return 0
7880

79-
# propagate=False: SDK must not inject agent identity into Baggage for this scope
81+
# propagate=False: SDK must not push agent identity into ContextVar for this scope
8082
if inner.get("gen_ai.agent.name") is not None or inner.get("gen_ai.agent.id") is not None:
8183
print(
8284
"FAIL: propagate=False but nested span has gen_ai.agent.* (should not):",
8385
inner,
8486
file=sys.stderr,
8587
)
8688
return 1
87-
print("OK: propagate=False — nested span has no gen_ai.agent.* from Baggage injection")
89+
print(
90+
"OK: propagate=False — nested span has no gen_ai.agent.* from identity propagation"
91+
)
8892
return 0
8993

9094

0 commit comments

Comments
 (0)