1use std::sync::{Arc, Mutex};
30use std::time::Instant;
31
32#[cfg(feature = "otel")]
33use opentelemetry::global;
34#[cfg(feature = "otel")]
35use opentelemetry::metrics::{Counter, Histogram};
36
37use async_trait::async_trait;
38use tracing::info;
39
40use crate::pricing::{calculate_cost_for, TokenCounts};
41use crate::types::{
42 ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError, TokenUsage,
43 ToolStream,
44};
45
46const CHARS_PER_TOKEN_ESTIMATE: u32 = 4;
48
49#[derive(Debug, Default)]
51struct MetricsState {
52 call_count: u64,
53 total_latency_ms: u64,
54 total_prompt_tokens: u64,
55 total_completion_tokens: u64,
56 total_tokens: u64,
57 errors_count: u64,
58 total_cost: f64,
59}
60
61#[derive(Debug, Clone)]
63pub struct MetricsReport {
64 pub provider_name: String,
66 pub call_count: u64,
68 pub total_latency_ms: u64,
70 pub avg_latency_ms: u64,
72 pub total_prompt_tokens: u64,
74 pub total_completion_tokens: u64,
76 pub total_tokens: u64,
78 pub errors_count: u64,
80 pub total_cost: f64,
82}
83
84#[cfg(feature = "otel")]
86struct OtelInstruments {
87 requests_total: Counter<u64>,
88 requests_duration_ms: Histogram<f64>,
89 tokens_prompt: Counter<u64>,
90 tokens_completion: Counter<u64>,
91 errors_total: Counter<u64>,
92 cost_total: Counter<f64>,
93}
94
95#[cfg(feature = "otel")]
96impl OtelInstruments {
97 fn new() -> Self {
98 let meter = global::meter("embacle");
99 Self {
100 requests_total: meter
101 .u64_counter("embacle.requests.total")
102 .with_description("Total LLM requests")
103 .build(),
104 requests_duration_ms: meter
105 .f64_histogram("embacle.requests.duration_ms")
106 .with_description("Request duration in milliseconds")
107 .build(),
108 tokens_prompt: meter
109 .u64_counter("embacle.tokens.prompt")
110 .with_description("Total prompt tokens consumed")
111 .build(),
112 tokens_completion: meter
113 .u64_counter("embacle.tokens.completion")
114 .with_description("Total completion tokens generated")
115 .build(),
116 errors_total: meter
117 .u64_counter("embacle.errors.total")
118 .with_description("Total error count")
119 .build(),
120 cost_total: meter
121 .f64_counter("embacle.cost.total")
122 .with_description("Total cost in USD")
123 .build(),
124 }
125 }
126}
127
128pub struct MetricsProvider {
143 inner: Box<dyn LlmProvider>,
144 state: Arc<Mutex<MetricsState>>,
145 #[cfg(feature = "otel")]
146 otel: OtelInstruments,
147}
148
149impl MetricsProvider {
150 pub fn new(inner: Box<dyn LlmProvider>) -> Self {
152 Self {
153 inner,
154 state: Arc::new(Mutex::new(MetricsState::default())),
155 #[cfg(feature = "otel")]
156 otel: OtelInstruments::new(),
157 }
158 }
159
160 pub fn report(&self) -> Result<MetricsReport, RunnerError> {
166 let state = self
167 .state
168 .lock()
169 .map_err(|_| RunnerError::internal("metrics lock poisoned"))?;
170 let divisor = state.call_count.max(1);
171 Ok(MetricsReport {
172 provider_name: self.inner.name().to_owned(),
173 call_count: state.call_count,
174 total_latency_ms: state.total_latency_ms,
175 avg_latency_ms: state.total_latency_ms / divisor,
176 total_prompt_tokens: state.total_prompt_tokens,
177 total_completion_tokens: state.total_completion_tokens,
178 total_tokens: state.total_tokens,
179 errors_count: state.errors_count,
180 total_cost: state.total_cost,
181 })
182 }
183
184 pub fn reset(&self) -> Result<(), RunnerError> {
190 let mut state = self
191 .state
192 .lock()
193 .map_err(|_| RunnerError::internal("metrics lock poisoned"))?;
194 *state = MetricsState::default();
195 Ok(())
196 }
197
198 fn compute_cost(
203 &self,
204 model: &str,
205 prompt_tokens: u64,
206 completion_tokens: u64,
207 usage: Option<&TokenUsage>,
208 ) -> f64 {
209 let count = |n: u64| i64::try_from(n).unwrap_or(i64::MAX);
210 let optional = |n: Option<u32>| i64::from(n.unwrap_or(0));
211 let counts = TokenCounts::new(count(prompt_tokens), count(completion_tokens))
212 .with_cache(
213 optional(usage.and_then(|u| u.cached_read_tokens)),
214 optional(usage.and_then(|u| u.cached_write_tokens)),
215 )
216 .with_reasoning(optional(usage.and_then(|u| u.reasoning_tokens)));
217 calculate_cost_for(self.inner.name(), model, &counts)
218 }
219}
220
221fn estimate_tokens(text: &str) -> u32 {
223 #[allow(clippy::cast_possible_truncation)]
224 let len = text.len() as u32;
225 len / CHARS_PER_TOKEN_ESTIMATE.max(1)
226}
227
228#[async_trait]
229impl LlmProvider for MetricsProvider {
230 fn name(&self) -> &'static str {
231 self.inner.name()
232 }
233
234 fn display_name(&self) -> &str {
235 self.inner.display_name()
236 }
237
238 fn capabilities(&self) -> LlmCapabilities {
239 self.inner.capabilities()
240 }
241
242 fn default_model(&self) -> &str {
243 self.inner.default_model()
244 }
245
246 fn available_models(&self) -> &[String] {
247 self.inner.available_models()
248 }
249
250 async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
251 let start = Instant::now();
252 let result = self.inner.complete(request).await;
253 #[allow(clippy::cast_possible_truncation)]
254 let elapsed_ms = start.elapsed().as_millis() as u64;
255
256 let mut state = self
257 .state
258 .lock()
259 .map_err(|_| RunnerError::internal("metrics lock poisoned"))?;
260 state.call_count += 1;
261 state.total_latency_ms += elapsed_ms;
262
263 #[cfg(feature = "otel")]
264 let otel_attrs: Vec<opentelemetry::KeyValue> = {
265 let mut attrs = vec![opentelemetry::KeyValue::new("provider", self.inner.name())];
266 if let Some(turn_id) = request.turn_id {
267 attrs.push(opentelemetry::KeyValue::new("turn_id", turn_id.to_string()));
268 }
269 attrs
270 };
271
272 if let Ok(response) = &result {
273 let usage = response.usage.as_ref();
274 let prompt_tokens = u64::from(
275 usage.map_or_else(|| estimate_prompt_tokens(request), |u| u.prompt_tokens),
276 );
277 let completion_tokens = u64::from(usage.map_or_else(
278 || estimate_tokens(&response.content),
279 |u| u.completion_tokens,
280 ));
281 let total = prompt_tokens + completion_tokens;
282
283 state.total_prompt_tokens += prompt_tokens;
284 state.total_completion_tokens += completion_tokens;
285 state.total_tokens += total;
286
287 let cost = self.compute_cost(&response.model, prompt_tokens, completion_tokens, usage);
288 state.total_cost += cost;
289
290 info!(
291 provider = self.inner.name(),
292 elapsed_ms, prompt_tokens, completion_tokens, cost, "metrics: complete() succeeded"
293 );
294
295 #[cfg(feature = "otel")]
296 {
297 let attrs = otel_attrs.as_slice();
298 self.otel.requests_total.add(1, attrs);
299 #[allow(clippy::cast_precision_loss)]
300 self.otel
301 .requests_duration_ms
302 .record(elapsed_ms as f64, attrs);
303 self.otel.tokens_prompt.add(prompt_tokens, attrs);
304 self.otel.tokens_completion.add(completion_tokens, attrs);
305 if cost > 0.0 {
306 self.otel.cost_total.add(cost, attrs);
307 }
308 }
309 } else {
310 state.errors_count += 1;
311 info!(
312 provider = self.inner.name(),
313 elapsed_ms, "metrics: complete() failed"
314 );
315
316 #[cfg(feature = "otel")]
317 {
318 self.otel.errors_total.add(1, otel_attrs.as_slice());
319 }
320 }
321
322 drop(state);
323
324 result
325 }
326
327 async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
329 self.inner.complete_stream(request).await
330 }
331
332 async fn complete_stream_with_tools(
333 &self,
334 request: &ChatRequest,
335 ) -> Result<ToolStream, RunnerError> {
336 self.inner.complete_stream_with_tools(request).await
337 }
338
339 async fn health_check(&self) -> Result<bool, RunnerError> {
340 self.inner.health_check().await
341 }
342}
343
344fn estimate_prompt_tokens(request: &ChatRequest) -> u32 {
346 let total_chars: usize = request.messages.iter().map(|m| m.content.len()).sum();
347 #[allow(clippy::cast_possible_truncation)]
348 let len = total_chars as u32;
349 len / CHARS_PER_TOKEN_ESTIMATE.max(1)
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::types::{
356 ChatMessage, ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider,
357 RunnerError, TokenUsage,
358 };
359 use async_trait::async_trait;
360 use std::sync::atomic::{AtomicU32, Ordering};
361
362 struct TestProvider {
363 provider_name: &'static str,
364 responses: Mutex<Vec<Result<ChatResponse, RunnerError>>>,
365 call_count: AtomicU32,
366 }
367
368 impl TestProvider {
369 fn new(responses: Vec<Result<ChatResponse, RunnerError>>) -> Self {
370 Self::named("test", responses)
371 }
372
373 fn named(name: &'static str, responses: Vec<Result<ChatResponse, RunnerError>>) -> Self {
375 Self {
376 provider_name: name,
377 responses: Mutex::new(responses),
378 call_count: AtomicU32::new(0),
379 }
380 }
381 }
382
383 #[async_trait]
384 impl LlmProvider for TestProvider {
385 fn name(&self) -> &'static str {
386 self.provider_name
387 }
388 fn display_name(&self) -> &str {
389 "Test Provider"
390 }
391 fn capabilities(&self) -> LlmCapabilities {
392 LlmCapabilities::text_only()
393 }
394 fn default_model(&self) -> &'static str {
395 "test-model"
396 }
397 fn available_models(&self) -> &[String] {
398 &[]
399 }
400
401 async fn complete(&self, _request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
402 self.call_count.fetch_add(1, Ordering::SeqCst);
403 let mut responses = self.responses.lock().expect("test lock"); if responses.is_empty() {
405 Ok(ChatResponse {
406 content: "default".to_owned(),
407 model: "test-model".to_owned(),
408 usage: None,
409 finish_reason: Some("stop".to_owned()),
410 warnings: None,
411 tool_calls: None,
412 })
413 } else {
414 responses.remove(0)
415 }
416 }
417
418 async fn complete_stream(&self, _request: &ChatRequest) -> Result<ChatStream, RunnerError> {
419 Err(RunnerError::internal("streaming not supported in test"))
420 }
421
422 async fn health_check(&self) -> Result<bool, RunnerError> {
423 Ok(true)
424 }
425 }
426
427 #[test]
428 fn fresh_report_is_zeroed() {
429 let provider = TestProvider::new(vec![]);
430 let metered = MetricsProvider::new(Box::new(provider));
431 let report = metered.report().unwrap(); assert_eq!(report.call_count, 0);
433 assert_eq!(report.total_latency_ms, 0);
434 assert_eq!(report.avg_latency_ms, 0);
435 assert_eq!(report.total_prompt_tokens, 0);
436 assert_eq!(report.total_completion_tokens, 0);
437 assert_eq!(report.total_tokens, 0);
438 assert_eq!(report.errors_count, 0);
439 assert!(
440 report.total_cost.abs() < f64::EPSILON,
441 "{}",
442 report.total_cost
443 );
444 assert_eq!(report.provider_name, "test");
445 }
446
447 #[tokio::test]
448 async fn call_count_increments() {
449 let provider = TestProvider::new(vec![
450 Ok(ChatResponse {
451 content: "hello world".to_owned(),
452 model: "test-model".to_owned(),
453 usage: Some(TokenUsage::new(10, 5, 15)),
454 finish_reason: Some("stop".to_owned()),
455 warnings: None,
456 tool_calls: None,
457 }),
458 Ok(ChatResponse {
459 content: "second".to_owned(),
460 model: "test-model".to_owned(),
461 usage: Some(TokenUsage::new(8, 3, 11)),
462 finish_reason: Some("stop".to_owned()),
463 warnings: None,
464 tool_calls: None,
465 }),
466 ]);
467 let metered = MetricsProvider::new(Box::new(provider));
468 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
469
470 metered.complete(&request).await.expect("first call"); metered.complete(&request).await.expect("second call"); let report = metered.report().unwrap(); assert_eq!(report.call_count, 2);
475 assert_eq!(report.total_prompt_tokens, 18);
476 assert_eq!(report.total_completion_tokens, 8);
477 assert_eq!(report.total_tokens, 26);
478 assert_eq!(report.errors_count, 0);
479 }
480
481 #[tokio::test]
482 async fn errors_count_on_failure() {
483 let provider = TestProvider::new(vec![Err(RunnerError::external_service("test", "boom"))]);
484 let metered = MetricsProvider::new(Box::new(provider));
485 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
486
487 let result = metered.complete(&request).await;
488 assert!(result.is_err());
489
490 let report = metered.report().unwrap(); assert_eq!(report.call_count, 1);
492 assert_eq!(report.errors_count, 1);
493 }
494
495 #[tokio::test]
496 async fn token_estimation_when_no_usage() {
497 let provider = TestProvider::new(vec![Ok(ChatResponse {
498 content: "abcdefghijklmnop".to_owned(), model: "test-model".to_owned(),
500 usage: None,
501 finish_reason: Some("stop".to_owned()),
502 warnings: None,
503 tool_calls: None,
504 })]);
505 let metered = MetricsProvider::new(Box::new(provider));
506 let request = ChatRequest::new(vec![ChatMessage::user("12345678")]); metered.complete(&request).await.expect("call"); let report = metered.report().unwrap(); assert_eq!(report.total_prompt_tokens, 2);
512 assert_eq!(report.total_completion_tokens, 4);
513 assert_eq!(report.total_tokens, 6);
514 }
515
516 #[test]
517 fn div_by_zero_guard_on_avg_latency() {
518 let provider = TestProvider::new(vec![]);
519 let metered = MetricsProvider::new(Box::new(provider));
520 let report = metered.report().unwrap(); assert_eq!(report.avg_latency_ms, 0);
523 }
524
525 #[tokio::test]
526 async fn reset_zeroes_counters() {
527 let provider = TestProvider::new(vec![Ok(ChatResponse {
528 content: "hello".to_owned(),
529 model: "test-model".to_owned(),
530 usage: Some(TokenUsage::new(5, 2, 7)),
531 finish_reason: Some("stop".to_owned()),
532 warnings: None,
533 tool_calls: None,
534 })]);
535 let metered = MetricsProvider::new(Box::new(provider));
536 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
537
538 metered.complete(&request).await.expect("call"); assert_eq!(metered.report().unwrap().call_count, 1); metered.reset().unwrap(); let report = metered.report().unwrap(); assert_eq!(report.call_count, 0);
544 assert_eq!(report.total_tokens, 0);
545 assert_eq!(report.errors_count, 0);
546 assert!(
547 report.total_cost.abs() < f64::EPSILON,
548 "{}",
549 report.total_cost
550 );
551 }
552
553 const FLASH_IN: f64 = 0.15 / 1_000_000.0;
559 const FLASH_OUT: f64 = 0.60 / 1_000_000.0;
560
561 fn flash_response(content: &str, usage: Option<TokenUsage>) -> ChatResponse {
562 ChatResponse {
563 content: content.to_owned(),
564 model: "gemini-2.5-flash".to_owned(),
565 usage,
566 finish_reason: Some("stop".to_owned()),
567 warnings: None,
568 tool_calls: None,
569 }
570 }
571
572 #[tokio::test]
573 async fn cost_with_known_model() {
574 let provider = TestProvider::named(
575 "gemini",
576 vec![Ok(flash_response(
577 "response",
578 Some(TokenUsage::new(1000, 500, 1500)),
579 ))],
580 );
581 let metered = MetricsProvider::new(Box::new(provider));
582 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
583 metered.complete(&request).await.expect("call"); let report = metered.report().unwrap(); let expected = 1000.0f64.mul_add(FLASH_IN, 500.0 * FLASH_OUT);
587 assert!(
588 (report.total_cost - expected).abs() < 1e-12,
589 "{}",
590 report.total_cost
591 );
592 }
593
594 #[tokio::test]
597 async fn cost_with_unknown_provider_is_zero() {
598 let provider = TestProvider::new(vec![Ok(flash_response(
599 "response",
600 Some(TokenUsage::new(1000, 500, 1500)),
601 ))]);
602 let metered = MetricsProvider::new(Box::new(provider));
603 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
604 metered.complete(&request).await.expect("call"); let report = metered.report().unwrap(); assert!(
608 report.total_cost.abs() < f64::EPSILON,
609 "{}",
610 report.total_cost
611 );
612 }
613
614 #[tokio::test]
615 async fn cost_with_unknown_model() {
616 let provider = TestProvider::named(
617 "gemini",
618 vec![Ok(ChatResponse {
619 content: "response".to_owned(),
620 model: "some-unknown-model".to_owned(),
621 usage: Some(TokenUsage::new(1000, 500, 1500)),
622 finish_reason: Some("stop".to_owned()),
623 warnings: None,
624 tool_calls: None,
625 })],
626 );
627 let metered = MetricsProvider::new(Box::new(provider));
628 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
629 metered.complete(&request).await.expect("call"); let report = metered.report().unwrap(); assert!(
633 report.total_cost.abs() < f64::EPSILON,
634 "{}",
635 report.total_cost
636 );
637 }
638
639 #[tokio::test]
640 async fn cost_accumulates() {
641 let provider = TestProvider::named(
642 "gemini",
643 vec![
644 Ok(flash_response("r1", Some(TokenUsage::new(1000, 500, 1500)))),
645 Ok(flash_response(
646 "r2",
647 Some(TokenUsage::new(2000, 1000, 3000)),
648 )),
649 ],
650 );
651 let metered = MetricsProvider::new(Box::new(provider));
652 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
653 metered.complete(&request).await.expect("call 1"); metered.complete(&request).await.expect("call 2"); let report = metered.report().unwrap(); let expected = 3000.0f64.mul_add(FLASH_IN, 1500.0 * FLASH_OUT);
658 assert!(
659 (report.total_cost - expected).abs() < 1e-12,
660 "{}",
661 report.total_cost
662 );
663 }
664
665 #[tokio::test]
668 async fn cost_credits_reported_cache_reads() {
669 let provider = TestProvider::named(
670 "gemini",
671 vec![Ok(flash_response(
672 "r",
673 Some(TokenUsage::new(1000, 0, 1000).with_cache(Some(1000), None)),
674 ))],
675 );
676 let metered = MetricsProvider::new(Box::new(provider));
677 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
678 metered.complete(&request).await.expect("call"); let report = metered.report().unwrap(); let expected = 1000.0 * FLASH_IN * 0.25;
682 assert!(
683 (report.total_cost - expected).abs() < 1e-12,
684 "{}",
685 report.total_cost
686 );
687 }
688
689 #[tokio::test]
690 async fn cost_with_estimated_tokens() {
691 let provider =
693 TestProvider::named("gemini", vec![Ok(flash_response("abcdefghijklmnop", None))]);
694 let metered = MetricsProvider::new(Box::new(provider));
695 let request = ChatRequest::new(vec![ChatMessage::user("12345678")]);
696 metered.complete(&request).await.expect("call"); let report = metered.report().unwrap(); let expected = 2.0f64.mul_add(FLASH_IN, 4.0 * FLASH_OUT);
700 assert!(report.total_cost > 0.0);
701 assert!(
702 (report.total_cost - expected).abs() < 1e-12,
703 "{}",
704 report.total_cost
705 );
706 }
707
708 #[tokio::test]
709 async fn reset_zeroes_cost() {
710 let provider = TestProvider::named(
711 "gemini",
712 vec![Ok(flash_response(
713 "response",
714 Some(TokenUsage::new(1000, 500, 1500)),
715 ))],
716 );
717 let metered = MetricsProvider::new(Box::new(provider));
718 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
719 metered.complete(&request).await.expect("call"); assert!(metered.report().unwrap().total_cost > 0.0); metered.reset().unwrap(); let cost = metered.report().unwrap().total_cost; assert!(cost.abs() < f64::EPSILON, "{cost}");
725 }
726}