Skip to content

Commit 3cd9889

Browse files
fix: update GenAIAttributeTransformer to return normalized spans and adjust tests accordingly
1 parent c23a591 commit 3cd9889

2 files changed

Lines changed: 117 additions & 102 deletions

File tree

src/sap_cloud_sdk/core/telemetry/genai_attribute_transformer.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import json
99
import logging
10-
from typing import Any, Dict, List, MutableMapping, Optional, Sequence, cast
10+
from typing import Any, Dict, List, Optional, Sequence
1111

1212
from opentelemetry.sdk.trace import ReadableSpan
1313
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
@@ -63,15 +63,17 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
6363
Returns:
6464
SpanExportResult from the wrapped exporter
6565
"""
66+
transformed = []
6667
for span in spans:
6768
try:
68-
self._normalize_attributes(span)
69+
transformed.append(self._normalize_attributes(span))
6970
except Exception as e:
7071
logger.debug(
7172
f"Error normalizing GenAI attributes for span {getattr(span, 'name', '<unknown>')}: {e}"
7273
)
74+
transformed.append(span)
7375

74-
return self.wrapped_exporter.export(spans)
76+
return self.wrapped_exporter.export(transformed)
7577

7678
def shutdown(self) -> None:
7779
"""Shutdown the wrapped exporter."""
@@ -92,24 +94,17 @@ def force_flush(self, timeout_millis: Optional[int] = None) -> bool:
9294
return self.wrapped_exporter.force_flush()
9395
return self.wrapped_exporter.force_flush(timeout_millis)
9496

95-
def _normalize_attributes(self, span: ReadableSpan) -> None:
97+
def _normalize_attributes(self, span: ReadableSpan) -> ReadableSpan:
9698
"""
97-
Perform minimal normalization on the span's attributes in-place.
99+
Return a new ReadableSpan with normalized attributes.
98100
Only removes standard attributes that were transformed, preserving custom/proprietary ones.
99-
100-
Args:
101-
span: The span to modify
102101
"""
103102
if not span.attributes:
104-
return
105-
106-
# Access the internal mutable attributes dict
107-
if not hasattr(span, "_attributes") or span._attributes is None:
108-
return
103+
return span
109104

110-
attrs = cast(MutableMapping[str, Any], span._attributes)
105+
attrs: Dict[str, Any] = dict(span.attributes)
111106

112-
# Only consider spans that have traceloop.* or llm.* or gen_ai.prompt.* or gen_ai.completion.* attributes
107+
# Only process spans that have traceloop.* or llm.* or gen_ai.prompt.* or gen_ai.completion.* attributes
113108
if not any(
114109
k.startswith(
115110
(
@@ -121,9 +116,8 @@ def _normalize_attributes(self, span: ReadableSpan) -> None:
121116
)
122117
for k in attrs.keys()
123118
):
124-
return
119+
return span
125120

126-
# Track which specific attributes to remove after transformation
127121
keys_to_remove = set()
128122

129123
model_name = attrs.get(self._TL_MODEL_NAME)
@@ -139,18 +133,29 @@ def _normalize_attributes(self, span: ReadableSpan) -> None:
139133
attrs["gen_ai.provider.name"] = provider
140134
keys_to_remove.add(self._TL_PROVIDER)
141135

142-
# Map usage attributes and track which ones were transformed
143136
keys_to_remove.update(self._map_llm_usage(attrs))
144-
145-
# Transform messages and collect keys to remove (all gen_ai.prompt.* and gen_ai.completion.*)
146137
keys_to_remove.update(self._get_message_keys_to_remove(attrs))
147138
self._transform_messages(attrs)
148139

149-
# Remove only the specific transformed attributes
150140
for key in keys_to_remove:
151141
attrs.pop(key, None)
152142

153-
def _map_llm_usage(self, attrs: MutableMapping[str, Any]) -> set:
143+
return ReadableSpan(
144+
name=span.name,
145+
context=span.context,
146+
parent=span.parent,
147+
resource=span.resource,
148+
attributes=attrs,
149+
events=span.events,
150+
links=span.links,
151+
kind=span.kind,
152+
instrumentation_scope=span.instrumentation_scope,
153+
status=span.status,
154+
start_time=span.start_time,
155+
end_time=span.end_time,
156+
)
157+
158+
def _map_llm_usage(self, attrs: Dict[str, Any]) -> set:
154159
"""
155160
Map llm.usage.* keys into gen_ai.usage.* keys.
156161
@@ -191,7 +196,7 @@ def _map_llm_usage(self, attrs: MutableMapping[str, Any]) -> set:
191196

192197
return transformed_keys
193198

194-
def _get_message_keys_to_remove(self, attrs: MutableMapping[str, Any]) -> set:
199+
def _get_message_keys_to_remove(self, attrs: Dict[str, Any]) -> set:
195200
"""
196201
Get all gen_ai.prompt.* and gen_ai.completion.* keys that should be removed.
197202
These are always removed since they're transformed to new format.
@@ -207,7 +212,7 @@ def _get_message_keys_to_remove(self, attrs: MutableMapping[str, Any]) -> set:
207212
keys_to_remove.add(key)
208213
return keys_to_remove
209214

210-
def _transform_messages(self, attrs: MutableMapping[str, Any]) -> None:
215+
def _transform_messages(self, attrs: Dict[str, Any]) -> None:
211216
"""
212217
Transform old-format gen_ai.prompt.* and gen_ai.completion.* attributes
213218
to new OTEL semconv 1.39.0 structured format.
@@ -239,7 +244,7 @@ def _transform_messages(self, attrs: MutableMapping[str, Any]) -> None:
239244
logger.debug(f"Failed to serialize output messages: {e}")
240245

241246
def _collect_indexed_attributes(
242-
self, attrs: MutableMapping[str, Any], prefix: str
247+
self, attrs: Dict[str, Any], prefix: str
243248
) -> Dict[int, Dict[str, Any]]:
244249
"""
245250
Collect indexed attributes like gen_ai.prompt.0.role, gen_ai.prompt.0.content

0 commit comments

Comments
 (0)