diff --git a/crates/core/src/observability/openinference.rs b/crates/core/src/observability/openinference.rs index caa1d2e3..709d3d11 100644 --- a/crates/core/src/observability/openinference.rs +++ b/crates/core/src/observability/openinference.rs @@ -726,6 +726,7 @@ impl OpenInferenceEventProcessor { oi::OPENINFERENCE_SPAN_KIND, OpenInferenceSpanKind::Tool, )); + push_projected_mark_attributes(&mut attributes, event); if orphan { attributes.push(KeyValue::new("nemo_relay.mark.orphan", true)); } @@ -1396,6 +1397,20 @@ fn mark_attributes(event: &Event) -> Vec { attributes } +fn push_projected_mark_attributes(attributes: &mut Vec, event: &Event) { + let mark_name = event.name().to_string(); + attributes.push(KeyValue::new(oi::tool::NAME, mark_name.clone())); + attributes.push(KeyValue::new(oi::tool_call::function::NAME, mark_name)); + + if let Some(data) = event.data().and_then(to_json_string) { + attributes.push(KeyValue::new(oi::output::VALUE, data)); + attributes.push(KeyValue::new(oi::output::MIME_TYPE, "application/json")); + } + if let Some(metadata) = event.metadata().and_then(to_json_string) { + attributes.push(KeyValue::new(oi::METADATA, metadata)); + } +} + fn common_attributes(event: &Event) -> Vec { let mut attributes = vec![ KeyValue::new( diff --git a/crates/core/tests/unit/observability/openinference_tests.rs b/crates/core/tests/unit/observability/openinference_tests.rs index 347617bd..6f3cfdc0 100644 --- a/crates/core/tests/unit/observability/openinference_tests.rs +++ b/crates/core/tests/unit/observability/openinference_tests.rs @@ -1061,6 +1061,11 @@ fn registered_subscriber_emits_spans_for_scope_push_pop_and_marks() { event_attributes.get("nemo_relay.mark.metadata.source"), Some(&"rust-test".to_string()) ); + assert!(!event_attributes.contains_key("tool.name")); + assert!(!event_attributes.contains_key("tool_call.function.name")); + assert!(!event_attributes.contains_key("output.value")); + assert!(!event_attributes.contains_key("output.mime_type")); + assert!(!event_attributes.contains_key("metadata")); } #[test] @@ -2541,7 +2546,8 @@ fn tool_projection_emits_generic_mark_as_parented_openinference_tool_span() { BaseEvent::builder() .parent_uuid(parent_uuid) .name("plugin.output_compacted") - .data(json!({"count": 3})) + .data(json!({"count": 3, "details": {"strategy": "compact"}})) + .metadata(json!({"producer": "example-plugin", "attempt": 1})) .build(), Some(EventCategory::custom()), Some( @@ -2584,6 +2590,38 @@ fn tool_projection_emits_generic_mark_as_parented_openinference_tool_span() { attributes.get("nemo_relay.mark.data.count"), Some(&"3".to_string()) ); + assert_eq!( + attributes.get("nemo_relay.mark.data.details"), + Some(&"{\"strategy\":\"compact\"}".to_string()) + ); + assert_eq!( + attributes.get("nemo_relay.mark.metadata.attempt"), + Some(&"1".to_string()) + ); + assert_eq!( + attributes.get("nemo_relay.mark.metadata.producer"), + Some(&"example-plugin".to_string()) + ); + assert_eq!( + attributes.get("tool.name"), + Some(&"plugin.output_compacted".to_string()) + ); + assert_eq!( + attributes.get("tool_call.function.name"), + Some(&"plugin.output_compacted".to_string()) + ); + assert_eq!( + attributes.get("output.value"), + Some(&"{\"count\":3,\"details\":{\"strategy\":\"compact\"}}".to_string()) + ); + assert_eq!( + attributes.get("output.mime_type"), + Some(&"application/json".to_string()) + ); + assert_eq!( + attributes.get("metadata"), + Some(&"{\"attempt\":1,\"producer\":\"example-plugin\"}".to_string()) + ); assert_eq!( attributes.get("nemo_relay.mark.category"), Some(&"custom".to_string()) @@ -2595,6 +2633,115 @@ fn tool_projection_emits_generic_mark_as_parented_openinference_tool_span() { assert!(!attributes.contains_key("nemo_relay.mark.orphan")); } +#[test] +fn tool_projection_omits_standard_payload_attributes_when_mark_payload_is_absent() { + let (provider, exporter) = make_provider(); + let mut processor = OpenInferenceEventProcessor::new_with_mark_projection( + provider.clone(), + "test-scope".to_string(), + MarkProjection::Tool, + ); + processor.process(&make_mark_event(None, "plugin.checkpoint", None)); + processor.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + let attributes = attr_map(&spans[0].attributes); + assert_eq!( + attributes.get("tool.name"), + Some(&"plugin.checkpoint".to_string()) + ); + assert_eq!( + attributes.get("tool_call.function.name"), + Some(&"plugin.checkpoint".to_string()) + ); + assert!(!attributes.contains_key("output.value")); + assert!(!attributes.contains_key("output.mime_type")); + assert!(!attributes.contains_key("metadata")); + assert!( + !attributes + .keys() + .any(|key| key.starts_with("nemo_relay.mark.data")) + ); + assert!( + !attributes + .keys() + .any(|key| key.starts_with("nemo_relay.mark.metadata")) + ); +} + +#[test] +fn tool_projection_handles_data_and_metadata_independently() { + let (provider, exporter) = make_provider(); + let mut processor = OpenInferenceEventProcessor::new_with_mark_projection( + provider.clone(), + "test-scope".to_string(), + MarkProjection::Tool, + ); + processor.process(&make_mark_event( + None, + "plugin.data_only", + Some(json!({"count": 3})), + )); + processor.process(&Event::Mark(MarkEvent::new( + BaseEvent::builder() + .name("plugin.metadata_only") + .metadata(json!({"attempt": 1})) + .build(), + None, + None, + ))); + processor.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 2); + + let data_only = spans + .iter() + .find(|span| span.name.as_ref() == "mark:plugin.data_only") + .unwrap(); + let data_attributes = attr_map(&data_only.attributes); + assert_eq!( + data_attributes.get("output.value"), + Some(&"{\"count\":3}".to_string()) + ); + assert_eq!( + data_attributes.get("output.mime_type"), + Some(&"application/json".to_string()) + ); + assert_eq!( + data_attributes.get("nemo_relay.mark.data.count"), + Some(&"3".to_string()) + ); + assert!(!data_attributes.contains_key("metadata")); + assert!( + !data_attributes + .keys() + .any(|key| key.starts_with("nemo_relay.mark.metadata")) + ); + + let metadata_only = spans + .iter() + .find(|span| span.name.as_ref() == "mark:plugin.metadata_only") + .unwrap(); + let metadata_attributes = attr_map(&metadata_only.attributes); + assert!(!metadata_attributes.contains_key("output.value")); + assert!(!metadata_attributes.contains_key("output.mime_type")); + assert_eq!( + metadata_attributes.get("metadata"), + Some(&"{\"attempt\":1}".to_string()) + ); + assert_eq!( + metadata_attributes.get("nemo_relay.mark.metadata.attempt"), + Some(&"1".to_string()) + ); + assert!( + !metadata_attributes + .keys() + .any(|key| key.starts_with("nemo_relay.mark.data")) + ); +} + #[test] fn tool_projection_exclusion_keeps_custom_mark_as_native_event() { let (provider, exporter) = make_provider();