Skip to main content

lc_core/observability/
mod.rs

1// lc-core/src/observability/mod.rs
2//! Unified, pluggable observability export.
3//!
4//! `TokenTrackingLLM` (token usage) and `AgentExecutor` (per-run metrics) both
5//! write through the same [`MetricsSink`] interface with an [`ObsEvent`] payload.
6//! The framework only provides the capability — no concrete sink is bundled here
7//! (see the `lc-observability` crate for `JsonLinesSink`/`MongoSink`). Failures
8//! are logged as `warn` and never interrupt the main flow.
9
10mod agent_metrics;
11mod error;
12
13pub use agent_metrics::AgentMetrics;
14pub use error::ObsError;
15
16use crate::language_models::TokenUsage;
17use serde::Serialize;
18
19/// One observability record (the unified export payload).
20#[derive(Debug, Clone, Serialize)]
21#[serde(tag = "kind", rename_all = "snake_case")]
22pub enum ObsEvent {
23    /// Token usage of a single LLM call (exported as it happens).
24    TokenUsage(TokenUsage),
25    /// Aggregated metrics of one agent run (exported once at the end).
26    AgentMetrics(AgentMetrics),
27    /// Priced USD cost of one LLM call (B3; exported as it happens).
28    Cost(CostEvent),
29}
30
31/// Priced cost of one LLM call (emitted by `CostTracker`).
32#[derive(Debug, Clone, Serialize)]
33pub struct CostEvent {
34    /// Run/session label when the tracker was scoped with one.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub scope: Option<String>,
37    /// Provider slug (`"openai"`, ...); `None` when undeclared.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub provider: Option<String>,
40    /// Model id as reported by the model.
41    pub model: String,
42    /// Prompt tokens of the call.
43    pub prompt_tokens: usize,
44    /// Completion tokens of the call.
45    pub completion_tokens: usize,
46    /// Priced USD cost (0.0 when no price entry matched).
47    pub cost_usd: f64,
48}
49
50/// Pluggable observability sink. The framework only provides the interface and
51/// binds no concrete plugin.
52#[async_trait::async_trait]
53pub trait MetricsSink: Send + Sync {
54    /// Pushes one record. Implementations must contain their own failures (or
55    /// let the framework `warn` on `Err`) — errors never propagate to the caller.
56    async fn export(&self, event: &ObsEvent) -> Result<(), ObsError>;
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use std::time::Duration;
63
64    fn sample_usage() -> TokenUsage {
65        TokenUsage {
66            prompt_tokens: 10,
67            completion_tokens: 5,
68            total_tokens: 15,
69        }
70    }
71
72    #[test]
73    fn obs_event_token_usage_serializes_with_kind_tag() {
74        let json = serde_json::to_string(&ObsEvent::TokenUsage(sample_usage())).unwrap();
75        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
76        assert_eq!(v["kind"], "token_usage");
77        assert_eq!(v["prompt_tokens"], 10);
78        assert_eq!(v["completion_tokens"], 5);
79        assert_eq!(v["total_tokens"], 15);
80    }
81
82    #[test]
83    fn obs_event_agent_metrics_serializes_with_kind_tag() {
84        let m = AgentMetrics {
85            trace_id: Some("trace-x".to_string()),
86            llm_calls: 2,
87            cache_hits: 0,
88            tool_calls: 1,
89            compactions: 1,
90            total_tokens: Some(30),
91            duration: Duration::from_millis(100),
92        };
93        let json = serde_json::to_string(&ObsEvent::AgentMetrics(m)).unwrap();
94        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
95        assert_eq!(v["kind"], "agent_metrics");
96        assert_eq!(v["llm_calls"], 2);
97        assert_eq!(v["tool_calls"], 1);
98        assert_eq!(v["total_tokens"], 30);
99    }
100
101    #[test]
102    fn obs_event_cost_serializes_with_kind_tag() {
103        let json = serde_json::to_string(&ObsEvent::Cost(CostEvent {
104            scope: Some("run-7".to_string()),
105            provider: Some("openai".to_string()),
106            model: "gpt-4o-mini".to_string(),
107            prompt_tokens: 1000,
108            completion_tokens: 1000,
109            cost_usd: 0.75,
110        }))
111        .unwrap();
112        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
113        assert_eq!(v["kind"], "cost");
114        assert_eq!(v["scope"], "run-7");
115        assert_eq!(v["provider"], "openai");
116        assert_eq!(v["model"], "gpt-4o-mini");
117        assert_eq!(v["cost_usd"], 0.75);
118    }
119}