77
88import json
99import logging
10- from typing import Any , Dict , List , MutableMapping , Optional , Sequence , cast
10+ from typing import Any , Dict , List , Optional , Sequence
1111
1212from opentelemetry .sdk .trace import ReadableSpan
1313from 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