174 lines
5.8 KiB
Rust
174 lines
5.8 KiB
Rust
//! 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<Utc>,
|
|
#[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<IngestItem> {
|
|
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);
|
|
}
|
|
}
|