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}
507
508/// A single top-N expensive LLM span
509#[derive(Debug, Clone, Serialize, Deserialize)]
510#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
511pub struct TopSpan {
512    pub trace_id: String,
513    pub span_id: String,
514    /// Span start time (nanoseconds since Unix epoch)
515    pub start_time: i64,
516    /// Span duration in nanoseconds
517    pub duration: i64,
518    pub model: Option<String>,
519    pub system: Option<String>,
520    pub session_id: Option<String>,
521    pub prompt_id: Option<String>,
522    pub input_tokens: u64,
523    pub output_tokens: u64,
524    pub cache_creation_tokens: u64,
525    pub cache_read_tokens: u64,
526    pub total_tokens: u64,
527}
528
529/// Distribution entry for a single finish reason
530#[derive(Debug, Clone, Serialize, Deserialize)]
531#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
532pub struct FinishReasonCount {
533    pub reason: String,
534    pub count: usize,
535}
536
537/// Latency / TTFT percentile statistics for LLM spans, grouped by model.
538#[derive(Debug, Clone, Serialize, Deserialize)]
539#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
540pub struct LatencyStats {
541    pub model: Option<String>,
542    pub count: usize,
543    pub avg_ms: f64,
544    pub p50_ms: i64,
545    pub p95_ms: i64,
546    pub p99_ms: i64,
547    /// TTFT is reported only when any span in the group carried a ttft attribute.
548    pub ttft_count: usize,
549    pub ttft_p50_ms: Option<i64>,
550    pub ttft_p95_ms: Option<i64>,
551    pub ttft_p99_ms: Option<i64>,
552}
553
554/// Error-rate summary for LLM spans grouped by model.
555#[derive(Debug, Clone, Serialize, Deserialize)]
556#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
557pub struct ErrorRateByModel {
558    pub model: Option<String>,
559    pub total: usize,
560    pub errors: usize,
561    /// Fraction in the range 0.0..1.0.
562    pub error_rate: f64,
563}
564
565/// Aggregated per-tool usage for tool-execution spans.
566#[derive(Debug, Clone, Serialize, Deserialize)]
567#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
568pub struct ToolUsage {
569    pub tool_name: String,
570    pub count: usize,
571    pub success_count: usize,
572    pub error_count: usize,
573    pub avg_duration_ms: f64,
574    pub total_duration_ms: i64,
575}
576
577/// Retry statistics across LLM spans.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
580pub struct RetryStats {
581    pub total_llm_calls: usize,
582    /// Calls with attempt > 1 (Claude Code) or comparable retry markers.
583    pub retried_calls: usize,
584    /// Sum of (attempt - 1) across all calls — total extra attempts.
585    pub extra_attempts: usize,
586    /// Fraction in the range 0.0..1.0.
587    pub retry_rate: f64,
588}