use crate::error::CompletionError;
use opentelemetry::{
global,
metrics::{Counter, Histogram, Meter},
KeyValue,
};
use std::time::Duration;
use tracing::debug;
pub struct Metrics {
request_latency: Histogram<f64>,
hook_duration: Histogram<f64>,
tool_calls: Counter<u64>,
tool_errors: Counter<u64>,
completion_requests: Counter<u64>,
completion_errors: Counter<u64>,
optimization_events: Counter<u64>,
}
impl Metrics {
pub fn new(meter: &Meter) -> Self {
Self {
request_latency: meter
.f64_histogram("sac.request.latency")
.with_description("Request latency in milliseconds")
.with_unit("ms")
.build(),
hook_duration: meter
.f64_histogram("sac.hook.duration")
.with_description("Hook execution duration in milliseconds")
.with_unit("ms")
.build(),
tool_calls: meter
.u64_counter("sac.tool.calls")
.with_description("Number of tool calls")
.build(),
tool_errors: meter
.u64_counter("sac.tool.errors")
.with_description("Number of tool call errors")
.build(),
completion_requests: meter
.u64_counter("sac.completion.requests")
.with_description("Number of completion requests")
.build(),
completion_errors: meter
.u64_counter("sac.completion.errors")
.with_description("Number of completion errors")
.build(),
optimization_events: meter
.u64_counter("sac.context.optimizations")
.with_description("Number of context optimization events")
.build(),
}
}
pub fn global() -> Self {
let meter = global::meter("sac");
Self::new(&meter)
}
pub fn record_request_latency(&self, duration: Duration, attributes: &[KeyValue]) {
self.request_latency
.record(duration.as_secs_f64() * 1000.0, attributes);
}
pub fn record_hook_duration(&self, hook_name: &str, stage: &str, duration: Duration) {
self.hook_duration.record(
duration.as_secs_f64() * 1000.0,
&[
KeyValue::new("hook_name", hook_name.to_string()),
KeyValue::new("stage", stage.to_string()),
],
);
}
pub fn record_tool_call(&self, tool_name: &str, success: bool) {
let attributes = &[
KeyValue::new("tool_name", tool_name.to_string()),
KeyValue::new("success", success),
];
self.tool_calls.add(1, attributes);
if !success {
self.tool_errors.add(1, attributes);
}
}
pub fn record_completion_request(&self, provider: &str, model: &str, success: bool) {
let attributes = &[
KeyValue::new("provider", provider.to_string()),
KeyValue::new("model", model.to_string()),
KeyValue::new("success", success),
];
self.completion_requests.add(1, attributes);
if !success {
self.completion_errors.add(1, attributes);
}
}
pub fn record_optimization(
&self,
strategy: &str,
messages_before: usize,
messages_after: usize,
) {
self.optimization_events.add(
1,
&[
KeyValue::new("strategy", strategy.to_string()),
KeyValue::new(
"messages_removed",
(messages_before - messages_after) as i64,
),
],
);
}
}
pub fn init_metrics() -> Metrics {
Metrics::global()
}
#[macro_export]
macro_rules! hook_span {
($request_id:expr) => {
tracing::info_span!("hook_chain", request_id = %$request_id)
};
}
#[macro_export]
macro_rules! hook_execution_span {
($hook_name:expr, $stage:expr) => {
tracing::info_span!("hook_execution", hook_name = %$hook_name, stage = %$stage)
};
}
#[macro_export]
macro_rules! tool_call_span {
($tool_name:expr, $call_id:expr) => {
tracing::info_span!("tool_call", tool_name = %$tool_name, call_id = %$call_id)
};
}
#[macro_export]
macro_rules! completion_span {
($provider:expr, $model:expr) => {
tracing::info_span!("completion", provider = %$provider, model = %$model)
};
}
#[derive(Debug, Clone)]
pub struct CompletionTiming {
pub latency_ms: f64,
pub input_tokens: u64,
pub output_tokens: u64,
pub provider: String,
pub model: String,
pub success: bool,
pub error_type: Option<String>,
}
impl CompletionTiming {
pub fn output_tps(&self) -> f64 {
if self.latency_ms > 0.0 {
(self.output_tokens as f64) / (self.latency_ms / 1000.0)
} else {
0.0
}
}
pub fn input_tps(&self) -> f64 {
if self.latency_ms > 0.0 {
(self.input_tokens as f64) / (self.latency_ms / 1000.0)
} else {
0.0
}
}
pub fn total_tps(&self) -> f64 {
if self.latency_ms > 0.0 {
((self.input_tokens + self.output_tokens) as f64) / (self.latency_ms / 1000.0)
} else {
0.0
}
}
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens
}
}
#[derive(Clone)]
pub struct LlmMetrics {
e2e_latency: Histogram<f64>,
output_tps: Histogram<f64>,
input_tps: Histogram<f64>,
total_tps: Histogram<f64>,
requests_total: Counter<u64>,
requests_success: Counter<u64>,
requests_failed: Counter<u64>,
tokens_input_total: Counter<u64>,
tokens_output_total: Counter<u64>,
}
impl LlmMetrics {
pub fn new(meter: &Meter) -> Self {
Self {
e2e_latency: meter
.f64_histogram("sac.llm.latency_ms")
.with_description("End-to-end completion latency in milliseconds")
.with_unit("ms")
.build(),
output_tps: meter
.f64_histogram("sac.llm.output_tokens_per_second")
.with_description("Output token generation throughput")
.with_unit("tokens/s")
.build(),
input_tps: meter
.f64_histogram("sac.llm.input_tokens_per_second")
.with_description("Input token processing throughput")
.with_unit("tokens/s")
.build(),
total_tps: meter
.f64_histogram("sac.llm.total_tokens_per_second")
.with_description("Total token throughput")
.with_unit("tokens/s")
.build(),
requests_total: meter
.u64_counter("sac.llm.requests_total")
.with_description("Total LLM completion requests")
.build(),
requests_success: meter
.u64_counter("sac.llm.requests_success")
.with_description("Successful LLM completion requests")
.build(),
requests_failed: meter
.u64_counter("sac.llm.requests_failed")
.with_description("Failed LLM completion requests")
.build(),
tokens_input_total: meter
.u64_counter("sac.llm.tokens_input_total")
.with_description("Total input tokens processed")
.build(),
tokens_output_total: meter
.u64_counter("sac.llm.tokens_output_total")
.with_description("Total output tokens generated")
.build(),
}
}
pub fn global() -> Self {
let meter = global::meter("sac.llm");
Self::new(&meter)
}
pub fn record(&self, timing: &CompletionTiming) {
let attributes = &[
KeyValue::new("provider", timing.provider.clone()),
KeyValue::new("model", timing.model.clone()),
];
self.e2e_latency.record(timing.latency_ms, attributes);
if timing.success && timing.output_tokens > 0 {
self.output_tps.record(timing.output_tps(), attributes);
self.input_tps.record(timing.input_tps(), attributes);
self.total_tps.record(timing.total_tps(), attributes);
}
self.requests_total.add(1, attributes);
if timing.success {
self.requests_success.add(1, attributes);
} else {
let error_attrs = &[
KeyValue::new("provider", timing.provider.clone()),
KeyValue::new("model", timing.model.clone()),
KeyValue::new("error_type", timing.error_type.clone().unwrap_or_default()),
];
self.requests_failed.add(1, error_attrs);
}
self.tokens_input_total.add(timing.input_tokens, attributes);
self.tokens_output_total
.add(timing.output_tokens, attributes);
debug!(
target: "sombrax_agentic_core::llm_metrics",
provider = %timing.provider,
model = %timing.model,
latency_ms = timing.latency_ms,
input_tokens = timing.input_tokens,
output_tokens = timing.output_tokens,
total_tokens = timing.total_tokens(),
output_tps = timing.output_tps(),
input_tps = timing.input_tps(),
total_tps = timing.total_tps(),
success = timing.success,
error_type = timing.error_type.as_deref().unwrap_or(""),
"llm_completion_metrics"
);
}
}
pub fn classify_completion_error(error: &CompletionError) -> String {
match error {
CompletionError::RateLimited { .. } => "rate_limited".to_string(),
CompletionError::AuthenticationFailed => "auth_error".to_string(),
CompletionError::HttpError(_) => "http_error".to_string(),
CompletionError::JsonError(_) => "json_error".to_string(),
CompletionError::ProviderError(_) => "provider_error".to_string(),
CompletionError::InvalidRequest(_) => "invalid_request".to_string(),
CompletionError::Cancelled => "cancelled".to_string(),
CompletionError::HookError(_) => "hook_error".to_string(),
CompletionError::ToolError(_) => "tool_error".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metrics_creation() {
let _metrics = Metrics::global();
}
#[test]
fn test_record_operations() {
let metrics = Metrics::global();
metrics.record_request_latency(Duration::from_millis(100), &[]);
metrics.record_hook_duration("TestHook", "pre_completion", Duration::from_millis(5));
metrics.record_tool_call("get_weather", true);
metrics.record_tool_call("get_weather", false);
metrics.record_completion_request("openai", "gpt-4", true);
metrics.record_optimization("recency", 100, 50);
}
#[test]
fn test_completion_timing_tps_calculations() {
let timing = CompletionTiming {
latency_ms: 1000.0, input_tokens: 100,
output_tokens: 500,
provider: "test".to_string(),
model: "test-model".to_string(),
success: true,
error_type: None,
};
assert!((timing.output_tps() - 500.0).abs() < 0.001);
assert!((timing.input_tps() - 100.0).abs() < 0.001);
assert!((timing.total_tps() - 600.0).abs() < 0.001);
assert_eq!(timing.total_tokens(), 600);
}
#[test]
fn test_completion_timing_tps_with_fractional_latency() {
let timing = CompletionTiming {
latency_ms: 500.0, input_tokens: 50,
output_tokens: 250,
provider: "test".to_string(),
model: "test-model".to_string(),
success: true,
error_type: None,
};
assert!((timing.output_tps() - 500.0).abs() < 0.001);
assert!((timing.input_tps() - 100.0).abs() < 0.001);
assert!((timing.total_tps() - 600.0).abs() < 0.001);
}
#[test]
fn test_completion_timing_zero_latency() {
let timing = CompletionTiming {
latency_ms: 0.0,
input_tokens: 100,
output_tokens: 500,
provider: "test".to_string(),
model: "test-model".to_string(),
success: true,
error_type: None,
};
assert_eq!(timing.output_tps(), 0.0);
assert_eq!(timing.input_tps(), 0.0);
assert_eq!(timing.total_tps(), 0.0);
}
#[test]
fn test_completion_timing_zero_tokens() {
let timing = CompletionTiming {
latency_ms: 1000.0,
input_tokens: 0,
output_tokens: 0,
provider: "test".to_string(),
model: "test-model".to_string(),
success: false,
error_type: Some("rate_limited".to_string()),
};
assert_eq!(timing.output_tps(), 0.0);
assert_eq!(timing.input_tps(), 0.0);
assert_eq!(timing.total_tps(), 0.0);
assert_eq!(timing.total_tokens(), 0);
}
#[test]
fn test_llm_metrics_creation() {
let _metrics = LlmMetrics::global();
}
#[test]
fn test_llm_metrics_record_success() {
let metrics = LlmMetrics::global();
let timing = CompletionTiming {
latency_ms: 1234.5,
input_tokens: 150,
output_tokens: 500,
provider: "openai".to_string(),
model: "gpt-4o".to_string(),
success: true,
error_type: None,
};
metrics.record(&timing);
}
#[test]
fn test_llm_metrics_record_failure() {
let metrics = LlmMetrics::global();
let timing = CompletionTiming {
latency_ms: 100.0,
input_tokens: 0,
output_tokens: 0,
provider: "anthropic".to_string(),
model: "claude-3-5-sonnet".to_string(),
success: false,
error_type: Some("rate_limited".to_string()),
};
metrics.record(&timing);
}
#[test]
fn test_classify_completion_error_rate_limited() {
let error = CompletionError::RateLimited {
retry_after_secs: Some(60),
};
assert_eq!(classify_completion_error(&error), "rate_limited");
}
#[test]
fn test_classify_completion_error_auth() {
let error = CompletionError::AuthenticationFailed;
assert_eq!(classify_completion_error(&error), "auth_error");
}
#[test]
fn test_classify_completion_error_http() {
let error = CompletionError::HttpError("connection refused".to_string());
assert_eq!(classify_completion_error(&error), "http_error");
}
#[test]
fn test_classify_completion_error_invalid_request() {
let error = CompletionError::InvalidRequest("missing field".to_string());
assert_eq!(classify_completion_error(&error), "invalid_request");
}
#[test]
fn test_classify_completion_error_cancelled() {
let error = CompletionError::Cancelled;
assert_eq!(classify_completion_error(&error), "cancelled");
}
#[test]
fn test_classify_completion_error_provider() {
let error = CompletionError::ProviderError("model not found".to_string());
assert_eq!(classify_completion_error(&error), "provider_error");
}
}