//! BusEvent → Langfuse ingestion mapping. //! //! Maps the common BusEvent kinds to Langfuse ingestion items (traces, //! generations, spans). Events without a `scroll_id` are dropped — //! Langfuse requires a trace id up-front. // The items below are only wired into the stream when the `server` // feature is on; the default-feature build keeps them for symmetry but // does not reference them, so allow dead-code warnings there. #![cfg_attr(not(feature = "server"), allow(dead_code))] use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; use dirigent_protocol::{streaming::BusEvent, Event}; /// A single Langfuse ingestion item. /// /// Batched into `{ "batch": [...] }` in `LangfuseClient::ingest_batch`. #[derive(Debug, Clone, Serialize)] pub struct IngestItem { pub id: String, // UUIDv7 pub timestamp: DateTime, #[serde(rename = "type")] pub kind: IngestKind, pub body: serde_json::Value, } #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "kebab-case")] #[allow(dead_code)] // SpanCreate/SpanUpdate reserved for future tool-call mapping pub enum IngestKind { TraceCreate, GenerationCreate, GenerationUpdate, SpanCreate, SpanUpdate, } pub fn bus_event_to_items(bus_event: &BusEvent) -> Vec { let Some(scroll_id) = bus_event.routing.scroll_id else { // No scroll_id binding yet — drop. Upstream callers may choose to // buffer pending events keyed by (connector_id, native_id) until // SessionRegistered arrives; Phase 4 scope: drop and log. return Vec::new(); }; let trace_id = scroll_id.to_string(); let now = Utc::now(); match &*bus_event.event { Event::SessionCreated { session, .. } => { // `session.title` is a `String`; fall back to the id if empty. let name = if session.title.is_empty() { session.id.clone() } else { session.title.clone() }; vec![IngestItem { id: Uuid::now_v7().to_string(), timestamp: now, kind: IngestKind::TraceCreate, body: serde_json::json!({ "id": trace_id, "name": name, }), }] } Event::MessageStarted { message, .. } => { vec![IngestItem { id: Uuid::now_v7().to_string(), timestamp: now, kind: IngestKind::GenerationCreate, body: serde_json::json!({ "id": message.id, "traceId": trace_id, "name": format!("{:?}", message.role), "startTime": message.created_at, }), }] } Event::MessageCompleted { message, .. } => { vec![IngestItem { id: Uuid::now_v7().to_string(), timestamp: now, kind: IngestKind::GenerationUpdate, body: serde_json::json!({ "id": message.id, "traceId": trace_id, "endTime": now, "output": serialize_content(&message.content), }), }] } Event::TurnComplete { .. } => Vec::new(), // captured by MessageCompleted // SessionUpdate::ToolCall* — would need a case-by-case mapping; out of // Phase 4 scope. Return empty for now. _ => Vec::new(), } } fn serialize_content(parts: &[dirigent_protocol::MessagePart]) -> serde_json::Value { serde_json::to_value(parts).unwrap_or(serde_json::Value::Null) } #[cfg(test)] mod tests { use super::*; use dirigent_protocol::streaming::{BusEvent, EventKind, EventOrigin, EventRouting}; use dirigent_protocol::{Event, Message, MessageRole, MessageStatus}; use std::sync::Arc; fn make_bus_event_with_scroll(event: Event, scroll_id: Uuid) -> BusEvent { BusEvent { routing: EventRouting { scroll_id: Some(scroll_id), connector_uid: Some(Uuid::new_v4()), connector_id: Some("c".into()), native_session_id: Some("s".into()), kind: EventKind::Message, }, origin: EventOrigin::Runtime, event: Arc::new(event), } } #[test] fn message_started_produces_generation_create() { let scroll_id = Uuid::new_v4(); let msg = Message { id: "m1".into(), session_id: "s".into(), role: MessageRole::Assistant, created_at: chrono::Utc::now(), content: vec![], status: MessageStatus::Streaming, metadata: None, }; let bus_event = make_bus_event_with_scroll( Event::MessageStarted { connector_id: "c".into(), message: msg, }, scroll_id, ); let items = bus_event_to_items(&bus_event); assert_eq!(items.len(), 1); assert!(matches!(items[0].kind, IngestKind::GenerationCreate)); } #[test] fn no_scroll_id_drops_event() { let event = Event::Connected; let bus_event = BusEvent { routing: EventRouting::default(), origin: EventOrigin::Runtime, event: Arc::new(event), }; let items = bus_event_to_items(&bus_event); assert_eq!(items.len(), 0); } #[test] fn unmapped_event_returns_empty() { // `Connected` is not one of our mapped variants even when a scroll_id // is bound → expect 0 items. let scroll_id = Uuid::new_v4(); let bus_event = make_bus_event_with_scroll(Event::Connected, scroll_id); let items = bus_event_to_items(&bus_event); assert_eq!(items.len(), 0); } }