Skip to content

Commit 6a18234

Browse files
committed
add llm prompts attribute support
1 parent c2b2c9f commit 6a18234

File tree

5 files changed

+319
-54
lines changed

5 files changed

+319
-54
lines changed

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/llo_handler.py

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ class PatternConfig(TypedDict, total=False):
132132
"role": ROLE_ASSISTANT,
133133
"source": "output",
134134
},
135+
"llm.prompts": {
136+
"type": PatternType.DIRECT,
137+
"role": ROLE_USER,
138+
"source": "prompt",
139+
},
135140
}
136141

137142

@@ -258,16 +263,17 @@ def process_spans(self, spans: Sequence[ReadableSpan]) -> List[ReadableSpan]:
258263
Processes a sequence of spans to extract and filter LLO attributes.
259264
260265
For each span, this method:
261-
1. Extracts LLO attributes and emits them as Gen AI Events
262-
2. Filters out LLO attributes from the span to maintain privacy
263-
3. Processes any LLO attributes in span events
266+
1. Collects all LLO attributes from span attributes and all span events
267+
2. Emits a single consolidated Gen AI Event with all collected LLO content
268+
3. Filters out LLO attributes from the span and its events to maintain privacy
264269
4. Preserves non-LLO attributes in the span
265270
266271
Handles LLO attributes from multiple frameworks:
267272
- Traceloop (indexed prompt/completion patterns and entity input/output)
268-
- OpenLit (direct prompt/completion patterns)
273+
- OpenLit (direct prompt/completion patterns, including from span events)
269274
- OpenInference (input/output values and structured messages)
270275
- Strands SDK (system prompts and tool results)
276+
- CrewAI (tasks output and results)
271277
272278
Args:
273279
spans: A sequence of OpenTelemetry ReadableSpan objects to process
@@ -278,8 +284,29 @@ def process_spans(self, spans: Sequence[ReadableSpan]) -> List[ReadableSpan]:
278284
modified_spans = []
279285

280286
for span in spans:
287+
# Collect all LLO attributes from both span attributes and events
288+
all_llo_attributes = {}
289+
290+
# Collect from span attributes
291+
if span.attributes is not None:
292+
for key, value in span.attributes.items():
293+
if self._is_llo_attribute(key):
294+
all_llo_attributes[key] = value
295+
296+
# Collect from span events
297+
if span.events:
298+
for event in span.events:
299+
if event.attributes:
300+
for key, value in event.attributes.items():
301+
if self._is_llo_attribute(key):
302+
all_llo_attributes[key] = value
303+
304+
# Emit a single consolidated event if we found any LLO attributes
305+
if all_llo_attributes:
306+
self._emit_llo_attributes(span, all_llo_attributes)
307+
308+
# Filter span attributes
281309
if span.attributes is not None:
282-
self._emit_llo_attributes(span, span.attributes)
283310
updated_attributes = self._filter_attributes(span.attributes)
284311
else:
285312
updated_attributes = None
@@ -294,27 +321,22 @@ def process_spans(self, spans: Sequence[ReadableSpan]) -> List[ReadableSpan]:
294321
else:
295322
span._attributes = updated_attributes
296323

297-
self.process_span_events(span)
324+
# Filter span events
325+
self._filter_span_events(span)
298326

299327
modified_spans.append(span)
300328

301329
return modified_spans
302330

303-
def process_span_events(self, span: ReadableSpan) -> None:
331+
def _filter_span_events(self, span: ReadableSpan) -> None:
304332
"""
305-
Process events within a span to extract and filter LLO attributes.
333+
Filter LLO attributes from span events.
306334
307-
For each event in the span, this method:
308-
1. Emits LLO attributes found in event attributes as Gen AI Events
309-
2. Filters out LLO attributes from event attributes
310-
3. Creates updated events with filtered attributes
311-
4. Replaces the original span events with updated events
312-
313-
This ensures that LLO attributes are properly handled even when they appear
314-
in span events rather than directly in the span's attributes.
335+
This method removes LLO attributes from event attributes while preserving
336+
the event structure and non-LLO attributes.
315337
316338
Args:
317-
span: The ReadableSpan to process events for
339+
span: The ReadableSpan to filter events for
318340
319341
Returns:
320342
None: The span is modified in-place
@@ -329,9 +351,6 @@ def process_span_events(self, span: ReadableSpan) -> None:
329351
updated_events.append(event)
330352
continue
331353

332-
if event.attributes is not None:
333-
self._emit_llo_attributes(span, event.attributes, event_timestamp=event.timestamp)
334-
335354
updated_event_attributes = self._filter_attributes(event.attributes)
336355

337356
if updated_event_attributes is not None and len(updated_event_attributes) != len(event.attributes):

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/llo_handler/test_llo_handler_events.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,104 @@ def test_group_messages_by_type_missing_fields(self):
460460
# Complete message goes to output
461461
self.assertEqual(len(result["output"]), 1)
462462
self.assertEqual(result["output"][0], {"role": "assistant", "content": "Complete message"})
463+
464+
def test_emit_llo_attributes_with_llm_prompts(self):
465+
"""
466+
Test that llm.prompts attribute is properly emitted in the input section.
467+
"""
468+
llm_prompts_content = "[{'role': 'system', 'content': [{'text': 'You are helpful.', 'type': 'text'}]}]"
469+
attributes = {
470+
"llm.prompts": llm_prompts_content,
471+
"gen_ai.completion.0.content": "I understand.",
472+
"gen_ai.completion.0.role": "assistant",
473+
}
474+
475+
span = self._create_mock_span(attributes)
476+
span.end_time = 1234567899
477+
span.instrumentation_scope = MagicMock()
478+
span.instrumentation_scope.name = "test.scope"
479+
480+
self.llo_handler._emit_llo_attributes(span, attributes)
481+
482+
self.event_logger_mock.emit.assert_called_once()
483+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
484+
485+
event_body = emitted_event.body
486+
487+
# Check that llm.prompts is in input section
488+
self.assertIn("input", event_body)
489+
self.assertIn("output", event_body)
490+
491+
input_messages = event_body["input"]["messages"]
492+
self.assertEqual(len(input_messages), 1)
493+
self.assertEqual(input_messages[0]["content"], llm_prompts_content)
494+
self.assertEqual(input_messages[0]["role"], "user")
495+
496+
# Check output section has the completion
497+
output_messages = event_body["output"]["messages"]
498+
self.assertEqual(len(output_messages), 1)
499+
self.assertEqual(output_messages[0]["content"], "I understand.")
500+
self.assertEqual(output_messages[0]["role"], "assistant")
501+
502+
def test_emit_llo_attributes_openlit_style_events(self):
503+
"""
504+
Test that LLO attributes from OpenLit-style span events are collected and emitted
505+
in a single consolidated event, not as separate events.
506+
"""
507+
# This test simulates the OpenLit pattern where prompt and completion are in span events
508+
# The span processor should collect from both and emit a single event
509+
510+
span_attributes = {"normal.attribute": "value"}
511+
512+
# Create events like OpenLit does
513+
prompt_event_attrs = {"gen_ai.prompt": "Explain quantum computing"}
514+
prompt_event = MagicMock(attributes=prompt_event_attrs, timestamp=1234567890)
515+
516+
completion_event_attrs = {"gen_ai.completion": "Quantum computing is..."}
517+
completion_event = MagicMock(attributes=completion_event_attrs, timestamp=1234567891)
518+
519+
span = self._create_mock_span(span_attributes)
520+
span.events = [prompt_event, completion_event]
521+
span.end_time = 1234567899
522+
span.instrumentation_scope = MagicMock()
523+
span.instrumentation_scope.name = "openlit.otel.tracing"
524+
525+
# Process the span (this would normally be called by process_spans)
526+
all_llo_attrs = {}
527+
528+
# Collect from span attributes
529+
for key, value in span_attributes.items():
530+
if self.llo_handler._is_llo_attribute(key):
531+
all_llo_attrs[key] = value
532+
533+
# Collect from events
534+
for event in span.events:
535+
if event.attributes:
536+
for key, value in event.attributes.items():
537+
if self.llo_handler._is_llo_attribute(key):
538+
all_llo_attrs[key] = value
539+
540+
# Emit consolidated event
541+
self.llo_handler._emit_llo_attributes(span, all_llo_attrs)
542+
543+
# Verify single event was emitted with both input and output
544+
self.event_logger_mock.emit.assert_called_once()
545+
emitted_event = self.event_logger_mock.emit.call_args[0][0]
546+
547+
event_body = emitted_event.body
548+
549+
# Both input and output should be in the same event
550+
self.assertIn("input", event_body)
551+
self.assertIn("output", event_body)
552+
553+
# Check input section
554+
input_messages = event_body["input"]["messages"]
555+
self.assertEqual(len(input_messages), 1)
556+
self.assertEqual(input_messages[0]["content"], "Explain quantum computing")
557+
self.assertEqual(input_messages[0]["role"], "user")
558+
559+
# Check output section
560+
output_messages = event_body["output"]["messages"]
561+
self.assertEqual(len(output_messages), 1)
562+
self.assertEqual(output_messages[0]["content"], "Quantum computing is...")
563+
self.assertEqual(output_messages[0]["role"], "assistant")

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/llo_handler/test_llo_handler_frameworks.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,52 @@ def test_collect_strands_sdk_messages(self):
393393
self.assertIsNotNone(tool_msg)
394394
self.assertEqual(tool_msg["role"], "assistant")
395395
self.assertEqual(tool_msg["source"], "output")
396+
397+
def test_collect_llm_prompts_messages(self):
398+
"""
399+
Verify llm.prompts attribute is collected as a user message with prompt source.
400+
"""
401+
attributes = {
402+
"llm.prompts": (
403+
"[{'role': 'system', 'content': [{'text': 'You are a helpful AI assistant.', 'type': 'text'}]}, "
404+
"{'role': 'user', 'content': [{'text': 'What are the benefits of using FastAPI?', 'type': 'text'}]}]"
405+
),
406+
"other.attribute": "not collected",
407+
}
408+
409+
span = self._create_mock_span(attributes)
410+
messages = self.llo_handler._collect_all_llo_messages(span, attributes)
411+
412+
self.assertEqual(len(messages), 1)
413+
message = messages[0]
414+
self.assertEqual(message["content"], attributes["llm.prompts"])
415+
self.assertEqual(message["role"], "user")
416+
self.assertEqual(message["source"], "prompt")
417+
418+
def test_collect_llm_prompts_with_other_messages(self):
419+
"""
420+
Verify llm.prompts works correctly alongside other LLO attributes.
421+
"""
422+
attributes = {
423+
"llm.prompts": "[{'role': 'system', 'content': 'System prompt'}]",
424+
"gen_ai.prompt": "Direct prompt",
425+
"gen_ai.completion": "Assistant response",
426+
}
427+
428+
span = self._create_mock_span(attributes)
429+
messages = self.llo_handler._collect_all_llo_messages(span, attributes)
430+
431+
self.assertEqual(len(messages), 3)
432+
433+
# Check llm.prompts message
434+
llm_prompts_msg = next((m for m in messages if m["content"] == attributes["llm.prompts"]), None)
435+
self.assertIsNotNone(llm_prompts_msg)
436+
self.assertEqual(llm_prompts_msg["role"], "user")
437+
self.assertEqual(llm_prompts_msg["source"], "prompt")
438+
439+
# Check other messages are still collected
440+
direct_prompt_msg = next((m for m in messages if m["content"] == "Direct prompt"), None)
441+
self.assertIsNotNone(direct_prompt_msg)
442+
443+
completion_msg = next((m for m in messages if m["content"] == "Assistant response"), None)
444+
self.assertIsNotNone(completion_msg)

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/llo_handler/test_llo_handler_patterns.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ def test_is_llo_attribute_strands_sdk_match(self):
7373
self.assertTrue(self.llo_handler._is_llo_attribute("system_prompt"))
7474
self.assertTrue(self.llo_handler._is_llo_attribute("tool.result"))
7575

76+
def test_is_llo_attribute_llm_prompts_match(self):
77+
"""
78+
Verify _is_llo_attribute recognizes llm.prompts pattern.
79+
"""
80+
self.assertTrue(self.llo_handler._is_llo_attribute("llm.prompts"))
81+
7682
def test_build_pattern_matchers_with_missing_regex(self):
7783
"""
7884
Test _build_pattern_matchers handles patterns with missing regex gracefully

0 commit comments

Comments
 (0)