Skip to main content

otelite_core/
api.rs

1//! Shared API response types for Otelite
2//!
3//! This module defines the canonical API response structures used across
4//! otelite-server, otelite-cli, and otelite-tui. All types derive both Serialize
5//! and Deserialize to support both server-side serialization and client-side
6//! deserialization.
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Standard error response for all API endpoints
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
14pub struct ErrorResponse {
15    /// Human-readable error message
16    pub error: String,
17    /// Machine-readable error code
18    pub code: String,
19    /// Optional additional details
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub details: Option<String>,
22}
23
24impl ErrorResponse {
25    /// Create a new error response
26    pub fn new(code: impl Into<String>, error: impl Into<String>) -> Self {
27        Self {
28            error: error.into(),
29            code: code.into(),
30            details: None,
31        }
32    }
33
34    /// Create an error response with details
35    pub fn with_details(
36        code: impl Into<String>,
37        error: impl Into<String>,
38        details: impl Into<String>,
39    ) -> Self {
40        Self {
41            error: error.into(),
42            code: code.into(),
43            details: Some(details.into()),
44        }
45    }
46
47    /// Create a bad request error
48    pub fn bad_request(message: impl Into<String>) -> Self {
49        Self::new("BAD_REQUEST", message)
50    }
51
52    /// Create a not found error
53    pub fn not_found(resource: impl Into<String>) -> Self {
54        Self::new("NOT_FOUND", format!("{} not found", resource.into()))
55    }
56
57    /// Create an internal server error
58    pub fn internal_error(message: impl Into<String>) -> Self {
59        Self::new("INTERNAL_ERROR", message)
60    }
61
62    /// Create a storage error
63    pub fn storage_error(operation: impl Into<String>) -> Self {
64        Self::with_details(
65            "STORAGE_ERROR",
66            format!("Storage operation failed: {}", operation.into()),
67            "Check storage configuration and disk space",
68        )
69    }
70}
71
72/// Response for log listing
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
75pub struct LogsResponse {
76    pub logs: Vec<LogEntry>,
77    pub total: usize,
78    pub limit: usize,
79    pub offset: usize,
80}
81
82/// Individual log entry for API response
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85pub struct LogEntry {
86    pub timestamp: i64,
87    pub severity: String,
88    pub severity_text: Option<String>,
89    pub body: String,
90    #[serde(default)]
91    pub attributes: HashMap<String, String>,
92    pub resource: Option<Resource>,
93    pub trace_id: Option<String>,
94    pub span_id: Option<String>,
95}
96
97/// Resource information
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
100pub struct Resource {
101    pub attributes: HashMap<String, String>,
102}
103
104/// Response for trace listing
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
107pub struct TracesResponse {
108    pub traces: Vec<TraceEntry>,
109    pub total: usize,
110    pub limit: usize,
111    pub offset: usize,
112}
113
114/// Individual trace entry (aggregated from spans)
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117pub struct TraceEntry {
118    pub trace_id: String,
119    pub root_span_name: String,
120    pub start_time: i64,
121    pub duration: i64,
122    pub span_count: usize,
123    pub service_names: Vec<String>,
124    pub has_errors: bool,
125}
126
127/// Detailed trace with all spans
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
130pub struct TraceDetail {
131    pub trace_id: String,
132    pub spans: Vec<SpanEntry>,
133    pub start_time: i64,
134    pub end_time: i64,
135    pub duration: i64,
136    pub span_count: usize,
137    pub service_names: Vec<String>,
138}
139
140/// Individual span entry
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
143pub struct SpanEntry {
144    pub span_id: String,
145    pub trace_id: String,
146    pub parent_span_id: Option<String>,
147    pub name: String,
148    pub kind: String,
149    pub start_time: i64,
150    pub end_time: i64,
151    pub duration: i64,
152    #[serde(default)]
153    pub attributes: HashMap<String, String>,
154    pub resource: Option<Resource>,
155    pub status: SpanStatus,
156    pub events: Vec<SpanEvent>,
157}
158
159/// Span status
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
162pub struct SpanStatus {
163    pub code: String,
164    pub message: Option<String>,
165}
166
167/// Span event
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
170pub struct SpanEvent {
171    pub name: String,
172    pub timestamp: i64,
173    #[serde(default)]
174    pub attributes: HashMap<String, String>,
175}
176
177/// Metric response
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
180pub struct MetricResponse {
181    pub name: String,
182    pub description: Option<String>,
183    pub unit: Option<String>,
184    pub metric_type: String,
185    pub value: MetricValue,
186    pub timestamp: i64,
187    #[serde(default)]
188    pub attributes: HashMap<String, String>,
189    pub resource: Option<Resource>,
190}
191
192/// Metric value (can be different types)
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(untagged)]
195pub enum MetricValue {
196    Gauge(f64),
197    Counter(i64),
198    Histogram(HistogramValue),
199    Summary(SummaryValue),
200}
201
202/// Histogram value
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct HistogramValue {
205    pub sum: f64,
206    pub count: u64,
207    pub buckets: Vec<HistogramBucket>,
208}
209
210/// Histogram bucket
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct HistogramBucket {
213    pub upper_bound: f64,
214    pub count: u64,
215}
216
217/// Summary value
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct SummaryValue {
220    pub sum: f64,
221    pub count: u64,
222    pub quantiles: Vec<Quantile>,
223}
224
225/// Quantile
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Quantile {
228    pub quantile: f64,
229    pub value: f64,
230}
231
232// Conversion implementations from telemetry types
233
234impl From<crate::telemetry::LogRecord> for LogEntry {
235    fn from(log: crate::telemetry::LogRecord) -> Self {
236        Self {
237            timestamp: log.timestamp,
238            severity: log.severity.as_str().to_string(),
239            severity_text: Some(log.severity.as_str().to_string()),
240            body: log.body,
241            attributes: log.attributes,
242            resource: log.resource.map(Resource::from),
243            trace_id: log.trace_id,
244            span_id: log.span_id,
245        }
246    }
247}
248
249impl From<crate::telemetry::Resource> for Resource {
250    fn from(resource: crate::telemetry::Resource) -> Self {
251        Self {
252            attributes: resource.attributes,
253        }
254    }
255}
256
257impl From<crate::telemetry::Span> for SpanEntry {
258    fn from(span: crate::telemetry::Span) -> Self {
259        use crate::telemetry::trace::{SpanKind, StatusCode};
260
261        let kind_str = match span.kind {
262            SpanKind::Internal => "Internal",
263            SpanKind::Server => "Server",
264            SpanKind::Client => "Client",
265            SpanKind::Producer => "Producer",
266            SpanKind::Consumer => "Consumer",
267        };
268
269        let status_code_str = match span.status.code {
270            StatusCode::Unset => "Unset",
271            StatusCode::Ok => "Ok",
272            StatusCode::Error => "Error",
273        };
274
275        Self {
276            span_id: span.span_id,
277            trace_id: span.trace_id,
278            parent_span_id: span.parent_span_id,
279            name: span.name,
280            kind: kind_str.to_string(),
281            start_time: span.start_time,
282            end_time: span.end_time,
283            duration: span.end_time - span.start_time,
284            attributes: span.attributes,
285            resource: span.resource.map(Resource::from),
286            status: SpanStatus {
287                code: status_code_str.to_string(),
288                message: span.status.message,
289            },
290            events: span
291                .events
292                .into_iter()
293                .map(|e| SpanEvent {
294                    name: e.name,
295                    timestamp: e.timestamp,
296                    attributes: e.attributes,
297                })
298                .collect(),
299        }
300    }
301}
302
303impl From<crate::telemetry::Metric> for MetricResponse {
304    fn from(metric: crate::telemetry::Metric) -> Self {
305        use crate::telemetry::metric::MetricType;
306
307        let (metric_type_str, value) = match metric.metric_type {
308            MetricType::Gauge(v) => ("gauge", MetricValue::Gauge(v)),
309            MetricType::Counter(v) => ("counter", MetricValue::Counter(v as i64)),
310            MetricType::Histogram {
311                count,
312                sum,
313                buckets,
314            } => (
315                "histogram",
316                MetricValue::Histogram(HistogramValue {
317                    sum,
318                    count,
319                    buckets: buckets
320                        .into_iter()
321                        .map(|b| HistogramBucket {
322                            upper_bound: b.upper_bound,
323                            count: b.count,
324                        })
325                        .collect(),
326                }),
327            ),
328            MetricType::Summary {
329                count,
330                sum,
331                quantiles,
332            } => (
333                "summary",
334                MetricValue::Summary(SummaryValue {
335                    sum,
336                    count,
337                    quantiles: quantiles
338                        .into_iter()
339                        .map(|q| Quantile {
340                            quantile: q.quantile,
341                            value: q.value,
342                        })
343                        .collect(),
344                }),
345            ),
346        };
347
348        Self {
349            name: metric.name,
350            description: metric.description,
351            unit: metric.unit,
352            metric_type: metric_type_str.to_string(),
353            value,
354            timestamp: metric.timestamp,
355            attributes: metric.attributes,
356            resource: metric.resource.map(Resource::from),
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_error_response_new() {
367        let err = ErrorResponse::new("TEST_ERROR", "Test error message");
368        assert_eq!(err.code, "TEST_ERROR");
369        assert_eq!(err.error, "Test error message");
370        assert!(err.details.is_none());
371    }
372
373    #[test]
374    fn test_error_response_with_details() {
375        let err =
376            ErrorResponse::with_details("TEST_ERROR", "Test error message", "Additional details");
377        assert_eq!(err.code, "TEST_ERROR");
378        assert_eq!(err.error, "Test error message");
379        assert_eq!(err.details, Some("Additional details".to_string()));
380    }
381
382    #[test]
383    fn test_error_response_bad_request() {
384        let err = ErrorResponse::bad_request("Invalid parameter");
385        assert_eq!(err.code, "BAD_REQUEST");
386        assert_eq!(err.error, "Invalid parameter");
387    }
388
389    #[test]
390    fn test_error_response_not_found() {
391        let err = ErrorResponse::not_found("Log entry");
392        assert_eq!(err.code, "NOT_FOUND");
393        assert_eq!(err.error, "Log entry not found");
394    }
395
396    #[test]
397    fn test_error_response_internal_error() {
398        let err = ErrorResponse::internal_error("Database connection failed");
399        assert_eq!(err.code, "INTERNAL_ERROR");
400        assert_eq!(err.error, "Database connection failed");
401    }
402
403    #[test]
404    fn test_error_response_storage_error() {
405        let err = ErrorResponse::storage_error("write");
406        assert_eq!(err.code, "STORAGE_ERROR");
407        assert!(err.error.contains("write"));
408        assert!(err.details.is_some());
409    }
410
411    #[test]
412    fn test_error_response_serialization() {
413        let err = ErrorResponse::with_details("TEST", "message", "details");
414        let json = serde_json::to_string(&err).unwrap();
415        assert!(json.contains("\"code\":\"TEST\""));
416        assert!(json.contains("\"error\":\"message\""));
417        assert!(json.contains("\"details\":\"details\""));
418    }
419
420    #[test]
421    fn test_error_response_deserialization() {
422        let json = r#"{"error":"test message","code":"TEST_CODE","details":"test details"}"#;
423        let err: ErrorResponse = serde_json::from_str(json).unwrap();
424        assert_eq!(err.code, "TEST_CODE");
425        assert_eq!(err.error, "test message");
426        assert_eq!(err.details, Some("test details".to_string()));
427    }
428}
429
430/// Token usage summary response for GenAI/LLM spans
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
433pub struct TokenUsageResponse {
434    /// Overall token usage summary
435    pub summary: TokenUsageSummary,
436    /// Token usage grouped by model
437    pub by_model: Vec<ModelUsage>,
438    /// Token usage grouped by system (provider)
439    pub by_system: Vec<SystemUsage>,
440}
441
442/// Overall token usage summary
443#[derive(Debug, Clone, Serialize, Deserialize)]
444#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
445pub struct TokenUsageSummary {
446    /// Total input tokens across all requests
447    pub total_input_tokens: u64,
448    /// Total output tokens across all requests
449    pub total_output_tokens: u64,
450    /// Total number of GenAI requests
451    pub total_requests: usize,
452    /// Total cache creation input tokens (Anthropic prompt caching)
453    #[serde(default)]
454    pub total_cache_creation_tokens: u64,
455    /// Total cache read input tokens (Anthropic prompt caching)
456    #[serde(default)]
457    pub total_cache_read_tokens: u64,
458}
459
460/// Token usage for a specific model
461#[derive(Debug, Clone, Serialize, Deserialize)]
462#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
463pub struct ModelUsage {
464    /// Model name (e.g., "gpt-4", "claude-sonnet-4-20250514")
465    pub model: String,
466    /// Input tokens for this model
467    pub input_tokens: u64,
468    /// Output tokens for this model
469    pub output_tokens: u64,
470    /// Number of requests for this model
471    pub requests: usize,
472}
473
474/// Token usage for a specific system (provider)
475#[derive(Debug, Clone, Serialize, Deserialize)]
476#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
477pub struct SystemUsage {
478    /// System name (e.g., "openai", "anthropic")
479    pub system: String,
480    /// Input tokens for this system
481    pub input_tokens: u64,
482    /// Output tokens for this system
483    pub output_tokens: u64,
484    /// Number of requests for this system
485    pub requests: usize,
486}
487
488/// A single time-bucketed cost/usage data point
489#[derive(Debug, Clone, Serialize, Deserialize)]
490#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
491pub struct CostSeriesPoint {
492    /// Bucket start timestamp in nanoseconds since Unix epoch
493    pub timestamp: i64,
494    /// Model name (nullable — spans without a model attribute are grouped under null)
495    pub model: Option<String>,
496    /// Input tokens in this bucket
497    pub input_tokens: u64,
498    /// Output tokens in this bucket
499    pub output_tokens: u64,
500    /// Cache creation input tokens (Anthropic prompt caching)
501    pub cache_creation_tokens: u64,
502    /// Cache read input tokens (Anthropic prompt caching)
503    pub cache_read_tokens: u64,
504    /// Number of requests in this bucket
505    pub requests: usize,
506    /// Estimated cost in USD for this bucket, computed server-side. `None` when
507    /// no pricing data matched the bucket's model.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub cost: Option<f64>,
510    /// Origin of the cost figure: "litellm", "fallback", or "none".
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub cost_source: Option<String>,
513}
514
515/// Sort dimension for top-N span queries.
516#[derive(Debug, Clone, Default, Serialize, Deserialize)]
517#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
518#[serde(rename_all = "snake_case")]
519pub enum TopSpanSort {
520    /// By total token count (default — same as before).
521    #[default]
522    TotalTokens,
523    /// By span duration (slowest first).
524    Duration,
525    /// By output/input token ratio (most verbose first).
526    OutputInputRatio,
527    /// By cache efficiency: worst cache-read rate (ascending) first.
528    CacheEfficiency,
529}
530
531/// A single top-N expensive LLM span
532#[derive(Debug, Clone, Serialize, Deserialize)]
533#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
534pub struct TopSpan {
535    pub trace_id: String,
536    pub span_id: String,
537    /// Span start time (nanoseconds since Unix epoch)
538    pub start_time: i64,
539    /// Span duration in nanoseconds
540    pub duration: i64,
541    pub model: Option<String>,
542    pub system: Option<String>,
543    pub session_id: Option<String>,
544    pub prompt_id: Option<String>,
545    pub input_tokens: u64,
546    pub output_tokens: u64,
547    pub cache_creation_tokens: u64,
548    pub cache_read_tokens: u64,
549    pub total_tokens: u64,
550    /// First finish/stop reason for this span (e.g. "max_tokens", "end_turn").
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub finish_reason: Option<String>,
553    /// `gen_ai.conversation.id` attribute if present.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub conversation_id: Option<String>,
556    /// Estimated cost in USD, computed server-side from the pricing database.
557    /// `None` when no pricing data matched this row's (model, system).
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub cost: Option<f64>,
560    /// Origin of the cost figure: "litellm", "fallback", or "none".
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub cost_source: Option<String>,
563    /// Human-readable tooltip explaining why cost is None (e.g.
564    /// "no pricing data for claude-foo on bedrock").
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub cost_reason: Option<String>,
567    /// Derived output token throughput (output_tokens / span_duration_sec).
568    /// Span duration includes network + queue time — not pure generation time.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub derived_output_tokens_per_sec: Option<f64>,
571}
572
573/// Aggregated cost/token row for a single session.
574#[derive(Debug, Clone, Serialize, Deserialize)]
575#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
576pub struct SessionCostRow {
577    pub session_id: String,
578    pub request_count: u64,
579    pub input_tokens: u64,
580    pub output_tokens: u64,
581    pub total_tokens: u64,
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub cost: Option<f64>,
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub cost_source: Option<String>,
586}
587
588/// Aggregated cost/token row for a single conversation.
589#[derive(Debug, Clone, Serialize, Deserialize)]
590#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
591pub struct ConversationCostRow {
592    pub conversation_id: String,
593    pub request_count: u64,
594    pub input_tokens: u64,
595    pub output_tokens: u64,
596    pub total_tokens: u64,
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub cost: Option<f64>,
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub cost_source: Option<String>,
601}
602
603/// Distribution entry for a single finish reason
604#[derive(Debug, Clone, Serialize, Deserialize)]
605#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
606pub struct FinishReasonCount {
607    pub reason: String,
608    pub count: usize,
609}
610
611/// Latency / TTFT percentile statistics for LLM spans, grouped by model.
612#[derive(Debug, Clone, Serialize, Deserialize)]
613#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
614pub struct LatencyStats {
615    pub model: Option<String>,
616    pub count: usize,
617    pub avg_ms: f64,
618    pub p50_ms: i64,
619    pub p95_ms: i64,
620    pub p99_ms: i64,
621    /// TTFT is reported only when any span in the group carried a ttft attribute.
622    pub ttft_count: usize,
623    pub ttft_p50_ms: Option<i64>,
624    pub ttft_p95_ms: Option<i64>,
625    pub ttft_p99_ms: Option<i64>,
626    /// Derived output token throughput (output_tokens / span_duration_sec).
627    /// Span duration includes network + queue time, NOT pure generation time.
628    /// Only set for spans where both output_tokens > 0 and duration > 0.
629    pub derived_tokens_per_sec_p50: Option<f64>,
630    pub derived_tokens_per_sec_p95: Option<f64>,
631    pub derived_tokens_per_sec_p99: Option<f64>,
632    /// Distribution of input token counts (context / prompt size).
633    pub input_tokens_p50: Option<i64>,
634    pub input_tokens_p95: Option<i64>,
635    pub input_tokens_p99: Option<i64>,
636    /// Distribution of output/input token ratio (generation verbosity).
637    pub output_input_ratio_p50: Option<f64>,
638    pub output_input_ratio_p95: Option<f64>,
639    pub output_input_ratio_p99: Option<f64>,
640}
641
642/// Error-rate summary for LLM spans grouped by model.
643#[derive(Debug, Clone, Serialize, Deserialize)]
644#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
645pub struct ErrorRateByModel {
646    pub model: Option<String>,
647    pub total: usize,
648    pub errors: usize,
649    /// Fraction in the range 0.0..1.0.
650    pub error_rate: f64,
651}
652
653/// Aggregated per-tool usage for tool-execution spans.
654#[derive(Debug, Clone, Serialize, Deserialize)]
655#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
656pub struct ToolUsage {
657    pub tool_name: String,
658    pub count: usize,
659    pub success_count: usize,
660    pub error_count: usize,
661    pub avg_duration_ms: f64,
662    pub total_duration_ms: i64,
663}
664
665/// Retry statistics across LLM spans.
666#[derive(Debug, Clone, Serialize, Deserialize)]
667#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
668pub struct RetryStats {
669    pub total_llm_calls: usize,
670    /// Calls with attempt > 1 (Claude Code) or comparable retry markers.
671    pub retried_calls: usize,
672    /// Sum of (attempt - 1) across all calls — total extra attempts.
673    pub extra_attempts: usize,
674    /// Fraction in the range 0.0..1.0.
675    pub retry_rate: f64,
676}
677
678/// Retrieval / RAG statistics aggregated across retriever spans.
679#[derive(Debug, Clone, Serialize, Deserialize)]
680#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
681pub struct RetrievalStats {
682    pub total_retrievals: usize,
683    pub avg_documents_per_query: f64,
684    /// None when no retrieval span emitted a document score.
685    pub avg_top_document_score: Option<f64>,
686    pub top_queries: Vec<TopRetrievalQuery>,
687}
688
689/// A single grouped retrieval query with aggregate stats.
690#[derive(Debug, Clone, Serialize, Deserialize)]
691#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
692pub struct TopRetrievalQuery {
693    pub query: String,
694    pub count: usize,
695    pub avg_documents: f64,
696    pub avg_top_score: Option<f64>,
697}
698
699/// Truncation rate (finish_reason = max_tokens/length) per model.
700#[derive(Debug, Clone, Serialize, Deserialize)]
701#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
702pub struct TruncationRateByModel {
703    pub model: Option<String>,
704    pub total: usize,
705    pub truncated: usize,
706    /// Fraction in the range 0.0..1.0.
707    pub rate: f64,
708}
709
710/// Cache token efficiency per model.
711/// `hit_rate` = cache_read_tokens / (cache_read_tokens + input_tokens).
712/// Only set when at least one of the token counts is non-zero.
713#[derive(Debug, Clone, Serialize, Deserialize)]
714#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
715pub struct CacheHitRateByModel {
716    pub model: Option<String>,
717    pub total_input_tokens: u64,
718    pub total_cache_read_tokens: u64,
719    pub total_cache_creation_tokens: u64,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub hit_rate: Option<f64>,
722}
723
724/// Distribution of `gen_ai.request.temperature` values across LLM calls.
725#[derive(Debug, Clone, Serialize, Deserialize)]
726#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
727pub struct TemperatureBucket {
728    /// Rounded to 2 decimal places. None = attribute not set.
729    pub temperature: Option<f64>,
730    pub count: usize,
731}
732
733/// Distribution of `gen_ai.request.max_tokens` values across LLM calls.
734#[derive(Debug, Clone, Serialize, Deserialize)]
735#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
736pub struct MaxTokensBucket {
737    /// None = attribute not set.
738    pub max_tokens: Option<i64>,
739    pub count: usize,
740}
741
742/// Distribution of request parameter settings (temperature, max_tokens).
743#[derive(Debug, Clone, Serialize, Deserialize)]
744#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
745pub struct RequestParamProfile {
746    pub temperature_buckets: Vec<TemperatureBucket>,
747    pub max_tokens_buckets: Vec<MaxTokensBucket>,
748}
749
750/// Turn-count distribution across all observed conversations.
751#[derive(Debug, Clone, Serialize, Deserialize)]
752#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
753pub struct ConversationDepthStats {
754    pub total_conversations: usize,
755    pub avg_turns: f64,
756    pub p50_turns: i64,
757    pub p95_turns: i64,
758    pub p99_turns: i64,
759}
760
761/// Single time-bucket point for calls-over-time series.
762#[derive(Debug, Clone, Serialize, Deserialize)]
763#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
764pub struct CallsSeriesPoint {
765    /// Bucket start timestamp in nanoseconds since Unix epoch.
766    pub timestamp: i64,
767    /// Model (None = not attributed).
768    pub model: Option<String>,
769    /// Number of LLM calls in this bucket.
770    pub requests: usize,
771}
772
773/// Per-(model, error_type) breakdown of error spans, bucketed into actionable categories.
774#[derive(Debug, Clone, Serialize, Deserialize)]
775#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
776pub struct ErrorTypeBreakdown {
777    pub model: Option<String>,
778    /// Raw `error.type` value as observed (or exception.type / HTTP status code).
779    pub error_type: String,
780    /// Coarse actionable bucket: "rate_limit" | "timeout" | "context_length" |
781    /// "content_filter" | "auth" | "server_error" | "unknown"
782    pub bucket: String,
783    pub count: usize,
784}
785
786/// A (request_model → response_model) pair that providers actually served.
787/// `differs == true` means the provider silently rerouted to a different model snapshot.
788#[derive(Debug, Clone, Serialize, Deserialize)]
789#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
790pub struct ModelDriftPair {
791    pub request_model: Option<String>,
792    pub response_model: Option<String>,
793    pub count: usize,
794    /// True when both fields are non-null and differ from each other.
795    pub differs: bool,
796}