Skip to main content

otelite_api/
server.rs

1//! Dashboard HTTP server implementation
2
3use crate::cache::LruCache;
4use crate::cached::{cache_handler, CacheState};
5use crate::config::DashboardConfig;
6use crate::pricing_cache::PricingCache;
7use crate::static_files;
8use axum::{
9    routing::{get, post},
10    Router,
11};
12use otelite_core::storage::StorageBackend;
13use serde::Serialize;
14use std::sync::Arc;
15use std::time::{Duration, Instant};
16use tower_http::trace::TraceLayer;
17use tracing::info;
18use utoipa::OpenApi;
19
20/// OpenAPI documentation
21#[derive(OpenApi)]
22#[openapi(
23    paths(
24        crate::api::health::health_check,
25        crate::api::stats::get_stats,
26        crate::api::admin::purge_all,
27        crate::api::help::api_help,
28        crate::api::logs::list_logs,
29        crate::api::logs::get_log,
30        crate::api::logs::export_logs,
31        crate::api::traces::list_traces,
32        crate::api::traces::get_trace,
33        crate::api::traces::get_trace_logs,
34        crate::api::traces::export_traces,
35        crate::api::metrics::list_metrics,
36        crate::api::metrics::list_metric_names,
37        crate::api::metrics::aggregate_metrics,
38        crate::api::metrics::get_metric_timeseries,
39        crate::api::metrics::export_metrics,
40        crate::api::genai::get_token_usage,
41        crate::api::genai::get_cost_series,
42        crate::api::genai::get_top_spans,
43        crate::api::genai::get_recent_retries,
44        crate::api::genai::get_top_sessions,
45        crate::api::genai::get_context_composition,
46        crate::api::genai::get_top_conversations,
47        crate::api::genai::get_finish_reasons,
48        crate::api::genai::get_distributions,
49        crate::api::genai::get_latency_stats,
50        crate::api::genai::get_latency_percentiles,
51        crate::api::genai::get_genai_capabilities,
52        crate::api::genai::get_model_performance,
53        crate::api::genai::get_error_rate,
54        crate::api::genai::get_tool_usage,
55        crate::api::genai::get_retry_stats,
56        crate::api::genai::get_retrieval_stats,
57        crate::api::genai::get_pricing_metadata,
58        crate::api::genai::get_agent_framework_defs,
59        crate::api::genai::get_truncation_rate,
60        crate::api::genai::get_cache_hit_rate,
61        crate::api::genai::get_agent_roles,
62        crate::api::genai::get_reasoning_share,
63        crate::api::genai::get_agents,
64        crate::api::genai::get_projects,
65        crate::api::genai::get_provider_mix,
66        crate::api::genai::get_request_param_profile,
67        crate::api::genai::get_conversation_depth,
68        crate::api::genai::get_latency_series,
69        crate::api::genai::get_calls_series,
70        crate::api::genai::get_latency_by_context,
71        crate::api::genai::get_error_types,
72        crate::api::genai::get_model_drift,
73        crate::api::genai::get_tool_approvals,
74        crate::api::genai::get_stop_reasons,
75        crate::api::genai::get_context_type_split,
76        crate::api::genai::get_tool_errors,
77        crate::api::genai::get_hour_of_day,
78        crate::api::genai::get_effort_breakdown,
79        crate::api::genai::get_efficiency_stats,
80        crate::api::genai::get_codex_ttft,
81        crate::api::genai::get_agent_project_rollup,
82        crate::api::genai::get_mcp_health,
83        crate::api::genai::get_guardian_stats,
84        crate::api::genai::get_multi_agent_stats,
85        crate::api::genai::get_codex_turn_breakdown,
86        crate::api::genai::get_codex_idle_ratio,
87        crate::api::genai::get_session_duration,
88        crate::api::genai::get_loc_efficiency,
89        crate::api::genai::get_rare_tool_sessions,
90        crate::api::genai::get_human_response_latency,
91        crate::api::genai::get_session_model_breakdown,
92        crate::api::genai::get_speed_distribution,
93        crate::api::genai::get_cross_tool_ttft,
94        crate::api::genai::get_hook_overhead,
95        crate::api::genai::get_bob_hook_overhead,
96        crate::api::genai::get_tool_switch_overhead,
97        crate::api::genai::get_tool_failure_rates,
98        crate::api::genai::get_daily_tool_mix,
99        crate::api::genai::get_productivity,
100        crate::api::genai::get_cost_projection,
101        crate::api::genai::get_cost_by_project,
102        crate::api::genai::get_session_depth_cost,
103        crate::api::genai::get_time_in_tool,
104        crate::api::genai::get_skill_activity,
105        crate::api::genai::get_skill_outcomes,
106        crate::api::genai::get_model_selection_heatmap,
107        crate::api::genai::get_session_quality_summary,
108        crate::api::genai::get_recent_errors,
109        crate::api::sessions::get_session_costs,
110        crate::api::sessions::get_session_cost_distribution,
111        crate::api::sessions::get_session_context,
112        crate::api::sessions::get_session_chains,
113    ),
114    components(
115        schemas(
116            otelite_core::api::ErrorResponse,
117            otelite_core::api::LogsResponse,
118            otelite_core::api::LogEntry,
119            otelite_core::api::Resource,
120            otelite_core::api::TracesResponse,
121            otelite_core::api::TraceEntry,
122            otelite_core::api::TraceDetail,
123            otelite_core::api::SpanEntry,
124            otelite_core::api::SpanStatus,
125            otelite_core::api::SpanEvent,
126            otelite_core::api::MetricResponse,
127            otelite_core::api::TokenUsageResponse,
128            otelite_core::api::TokenUsageSummary,
129            otelite_core::api::ModelUsage,
130            otelite_core::api::SystemUsage,
131            otelite_core::api::CostSeriesPoint,
132            otelite_core::api::TopSpan,
133            otelite_core::api::FinishReasonCount,
134            otelite_core::api::LatencyStats,
135            otelite_core::api::LatencyPercentilePoint,
136            otelite_core::api::LatencyPercentileSeries,
137            otelite_core::api::LatencyPercentilesResponse,
138            otelite_core::api::DistributionBucket,
139            otelite_core::api::DistributionStats,
140            otelite_core::api::DistributionResponse,
141            otelite_core::api::GenAiMetricCapability,
142            otelite_core::api::GenAiCorrelationProvenance,
143            otelite_core::api::GenAiCapabilityReport,
144            otelite_core::api::GenAiCapabilityResponse,
145            otelite_core::api::ModelPerformanceWindow,
146            otelite_core::api::ModelPerformanceQuery,
147            otelite_core::api::ModelPerformanceResponse,
148            otelite_core::api::ModelPerformanceIdentity,
149            otelite_core::api::ModelPerformanceMetric,
150            otelite_core::api::ModelPerformanceSample,
151            otelite_core::api::ModelPerformancePercentile,
152            otelite_core::api::ModelPerformanceDelta,
153            otelite_core::api::ModelPerformanceCounts,
154            otelite_core::api::ModelPerformanceErrorRate,
155            otelite_core::api::ModelPerformanceErrorValue,
156            otelite_core::api::ModelPerformanceDiagnosis,
157            otelite_core::model_performance::ModelPerformanceAssessment,
158            otelite_core::model_performance::ModelPerformanceMetricAssessment,
159            otelite_core::model_performance::PerformanceMetricKind,
160            otelite_core::model_performance::PerformanceChangeClass,
161            otelite_core::model_performance::PerformanceConfidence,
162            otelite_core::model_performance::TtftTrust,
163            otelite_core::model_performance::WorkloadShiftEvidence,
164            crate::api::genai::ModelPerformanceQueryParams,
165            otelite_core::api::ErrorRateByModel,
166            otelite_core::api::ToolUsage,
167            otelite_core::api::RetryStats,
168            otelite_core::api::RetryIncident,
169            otelite_core::api::RetrievalStats,
170            otelite_core::api::TopRetrievalQuery,
171            crate::api::health::HealthResponse,
172            crate::api::stats::StatsResponse,
173            crate::api::admin::PurgeAllResponse,
174            crate::api::metrics::AggregateResponse,
175            crate::api::metrics::TimeBucket,
176            crate::api::metrics::TimeseriesQuery,
177            crate::api::genai::TokenUsageQuery,
178            crate::api::genai::CostSeriesQuery,
179            crate::api::genai::TopSpansQuery,
180            crate::api::genai::TopGroupQuery,
181            crate::api::genai::FinishReasonsQuery,
182            crate::api::genai::LatencyQuery,
183            crate::api::genai::ErrorRateQuery,
184            crate::api::genai::ToolUsageQuery,
185            crate::api::genai::RetryStatsQuery,
186            crate::api::genai::RecentRetriesQuery,
187            crate::api::genai::RetrievalStatsQuery,
188            crate::api::genai::PricingMetadata,
189            otelite_core::agent_frameworks::AgentFrameworkRecognizer,
190            otelite_core::api::ToolApprovalStats,
191            otelite_core::api::ToolApprovalEntry,
192            otelite_core::api::StopReasonCount,
193            otelite_core::api::ContextTypeSplit,
194            otelite_core::api::ToolErrorEntry,
195            otelite_core::api::HourOfDayBucket,
196            otelite_core::api::ReasoningShareResponse,
197            otelite_core::api::ReasoningShareByModel,
198            otelite_core::api::ReasoningEffortEntry,
199            otelite_core::api::SessionCost,
200            otelite_core::api::SessionCostResponse,
201            otelite_core::api::CostBucket,
202            otelite_core::api::CostDistributionResponse,
203            otelite_core::api::ProjectRollup,
204            otelite_core::api::ProjectTopModel,
205            otelite_core::api::ProjectRollupResponse,
206            otelite_core::api::SessionContextResponse,
207            otelite_core::api::SessionContextSession,
208            otelite_core::api::SessionContextSpan,
209            otelite_core::api::SessionContextLog,
210            otelite_core::api::SessionContextMetric,
211            otelite_core::api::SessionContextTimelineEvent,
212            crate::api::sessions::SessionContextQuery,
213            otelite_core::api::EffortBreakdownRow,
214            otelite_core::api::EffortBreakdownResponse,
215            otelite_core::api::EfficiencyStats,
216            otelite_core::api::AgentEfficiencyRow,
217            otelite_core::api::CodexTtftByModel,
218            otelite_core::api::CodexTtftResponse,
219            otelite_core::api::ProjectRollupEntry,
220            otelite_core::api::AgentProjectRollupResponse,
221            otelite_core::api::McpHealthEntry,
222            otelite_core::api::McpHealthResponse,
223            otelite_core::api::GuardianRiskLevelEntry,
224            otelite_core::api::GuardianActionEntry,
225            otelite_core::api::GuardianStatsResponse,
226            otelite_core::api::MultiAgentRoleEntry,
227            otelite_core::api::MultiAgentStatsResponse,
228            otelite_core::api::CodexTurnBreakdownRow,
229            otelite_core::api::CodexTurnBreakdownResponse,
230            otelite_core::api::SessionModelRow,
231            otelite_core::api::SessionModelBreakdown,
232            otelite_core::api::SpeedBucket,
233            otelite_core::api::SpeedDistribution,
234            otelite_core::api::CrossToolTtftRow,
235            otelite_core::api::CrossToolTtftResponse,
236            otelite_core::api::HookOverheadRow,
237            otelite_core::api::HookOverheadResponse,
238            otelite_core::api::ToolFailureRow,
239            otelite_core::api::ToolFailureRatesResponse,
240            otelite_core::api::DailyToolMixRow,
241            otelite_core::api::DailyToolMixResponse,
242            otelite_core::api::SkillActivityRow,
243            otelite_core::api::SkillActivityResponse,
244            otelite_core::api::SkillOutcomeRow,
245            otelite_core::api::SkillOutcomesResponse,
246            otelite_core::api::ModelSelectionRow,
247            otelite_core::api::ModelSelectionHeatmapResponse,
248            otelite_core::api::SessionQuality,
249            otelite_core::api::SessionQualitySummary,
250            otelite_core::api::RecentErrorRow,
251            otelite_core::api::RecentErrorsResponse,
252            crate::api::genai::RecentErrorsQuery,
253        )
254    ),
255    tags(
256        (name = "health", description = "Health check endpoints"),
257        (name = "stats", description = "Storage statistics endpoints"),
258        (name = "help", description = "API documentation and help"),
259        (name = "logs", description = "Log query and export endpoints"),
260        (name = "traces", description = "Trace query and export endpoints"),
261        (name = "metrics", description = "Metric query and aggregation endpoints"),
262        (name = "genai", description = "GenAI/LLM token usage and analytics endpoints"),
263        (name = "admin", description = "Administrative endpoints for data management")
264    ),
265    info(
266        title = "Otelite API",
267        version = "1.0.0",
268        description = "OpenTelemetry data query and visualization API",
269        contact(
270            name = "Otelite",
271            url = "https://github.com/yourusername/otelite"
272        )
273    )
274)]
275struct ApiDoc;
276
277/// Shared application state
278#[derive(Clone)]
279pub struct AppState {
280    pub storage: Arc<dyn StorageBackend>,
281    pub cache: QueryCache,
282    /// Short-TTL response cache + in-flight dedup for read-only endpoints.
283    pub cache_state: Arc<CacheState>,
284    /// LiteLLM pricing database, refreshed periodically in the background.
285    pub pricing: PricingCache,
286    /// Time at which the server started (for uptime calculation)
287    pub start_time: Arc<Instant>,
288    /// OTLP receiver ports, populated from DashboardConfig
289    pub otlp_grpc_port: u16,
290    pub otlp_http_port: u16,
291}
292
293/// Cache for query results
294#[derive(Clone)]
295pub struct QueryCache {
296    /// Cache for logs queries (key: query params hash, value: JSON response)
297    pub logs: LruCache<String, String>,
298    /// Cache for traces queries
299    pub traces: LruCache<String, String>,
300    /// Cache for metrics queries
301    pub metrics: LruCache<String, String>,
302}
303
304impl QueryCache {
305    /// Create a new query cache with default settings
306    pub fn new() -> Self {
307        // Cache up to 100 queries per type, with 5 minute TTL
308        let max_size = 100;
309        let ttl = Duration::from_secs(300);
310
311        Self {
312            logs: LruCache::new(max_size, ttl),
313            traces: LruCache::new(max_size, ttl),
314            metrics: LruCache::new(max_size, ttl),
315        }
316    }
317
318    /// Create cache key from query parameters
319    pub fn make_key<T: Serialize>(params: &T) -> String {
320        // Simple serialization-based key
321        serde_json::to_string(params).unwrap_or_default()
322    }
323
324    /// Clear every cached query result. Called after data-mutating
325    /// operations so stale data is never served.
326    pub fn clear_all(&self) {
327        self.logs.clear();
328        self.traces.clear();
329        self.metrics.clear();
330    }
331}
332
333impl Default for QueryCache {
334    fn default() -> Self {
335        Self::new()
336    }
337}
338
339/// Dashboard server
340/// A handle that, when triggered, starts the dashboard's graceful
341/// shutdown (stop accepting, finish in-flight requests).
342#[derive(Clone)]
343pub struct DashboardShutdown(Arc<tokio::sync::Notify>);
344
345impl DashboardShutdown {
346    /// Start the graceful shutdown.
347    pub fn trigger(&self) {
348        self.0.notify_waiters();
349    }
350}
351
352pub struct DashboardServer {
353    config: Arc<DashboardConfig>,
354    state: AppState,
355    shutdown_notify: Arc<tokio::sync::Notify>,
356}
357
358impl DashboardServer {
359    /// Create a new dashboard server (production path: the pricing
360    /// cache runs its background LiteLLM refresher).
361    pub fn new(config: DashboardConfig, storage: Arc<dyn StorageBackend>) -> Self {
362        Self::with_pricing_cache(config, storage, PricingCache::new().spawn_refresher())
363    }
364
365    /// Create a dashboard server with a caller-managed pricing cache.
366    ///
367    /// Tests use this with a plain [`PricingCache::new`] (no background
368    /// refresher) so pricing behaviour is deterministic — the empty
369    /// database plus the Claude fallback table, with no live network
370    /// fetch racing the assertions.
371    pub fn with_pricing_cache(
372        config: DashboardConfig,
373        storage: Arc<dyn StorageBackend>,
374        pricing: PricingCache,
375    ) -> Self {
376        let state = AppState {
377            storage,
378            cache: QueryCache::new(),
379            cache_state: CacheState::new(),
380            pricing,
381            start_time: Arc::new(Instant::now()),
382            otlp_grpc_port: config.otlp_grpc_port,
383            otlp_http_port: config.otlp_http_port,
384        };
385
386        Self {
387            config: Arc::new(config),
388            state,
389            shutdown_notify: Arc::new(tokio::sync::Notify::new()),
390        }
391    }
392
393    /// Handle for triggering graceful shutdown from a signal handler.
394    pub fn shutdown_handle(&self) -> DashboardShutdown {
395        DashboardShutdown(Arc::clone(&self.shutdown_notify))
396    }
397
398    /// Build the router with all routes
399    pub fn build_router(&self) -> Router {
400        let api = Router::new()
401            // API routes - Health
402            .route("/api/health", get(crate::api::health_check))
403            // API routes - Help
404            .route("/api/help", get(crate::api::api_help))
405            // API routes - Logs
406            .route("/api/logs", get(crate::api::logs::list_logs))
407            .route("/api/logs/export", get(crate::api::logs::export_logs))
408            .route("/api/logs/{timestamp}", get(crate::api::logs::get_log))
409            // API routes - Traces
410            .route("/api/traces", get(crate::api::traces::list_traces))
411            .route("/api/traces/export", get(crate::api::traces::export_traces))
412            .route("/api/traces/{trace_id}", get(crate::api::traces::get_trace))
413            .route("/api/traces/{trace_id}/logs", get(crate::api::traces::get_trace_logs))
414            // API routes - Metrics
415            .route("/api/metrics", get(crate::api::metrics::list_metrics))
416            .route("/api/metrics/names", get(crate::api::metrics::list_metric_names))
417            .route("/api/metrics/aggregate", get(crate::api::metrics::aggregate_metrics))
418            .route("/api/metrics/{name}/timeseries", get(crate::api::metrics::get_metric_timeseries))
419            .route("/api/metrics/export", get(crate::api::metrics::export_metrics))
420            // API routes - Resource keys typeahead
421            .route("/api/resource-keys", get(crate::api::resource_keys::get_resource_keys))
422            // API routes - Stats
423            .route("/api/stats", get(crate::api::stats::get_stats))
424            // API routes - Admin
425            .route("/api/admin/purge", post(crate::api::admin::purge_all))
426            // API routes - GenAI
427            .route("/api/genai/usage", get(crate::api::get_token_usage))
428            .route("/api/genai/cost_series", get(crate::api::genai::get_cost_series))
429            .route("/api/genai/top_spans", get(crate::api::genai::get_top_spans))
430            .route("/api/genai/retries", get(crate::api::genai::get_recent_retries))
431            .route("/api/genai/top_sessions", get(crate::api::genai::get_top_sessions))
432            .route(
433                "/api/genai/context_composition",
434                get(crate::api::genai::get_context_composition),
435            )
436            .route("/api/genai/top_conversations", get(crate::api::genai::get_top_conversations))
437            .route("/api/genai/finish_reasons", get(crate::api::genai::get_finish_reasons))
438            .route("/api/genai/latency_stats", get(crate::api::genai::get_latency_stats))
439            .route(
440                "/api/genai/latency_percentiles",
441                get(crate::api::genai::get_latency_percentiles),
442            )
443            .route("/api/genai/distributions", get(crate::api::genai::get_distributions))
444            .route("/api/genai/capabilities", get(crate::api::genai::get_genai_capabilities))
445            .route(
446                "/api/genai/model-performance",
447                get(crate::api::genai::get_model_performance),
448            )
449            .route("/api/genai/error_rate", get(crate::api::genai::get_error_rate))
450            .route("/api/genai/tool_usage", get(crate::api::genai::get_tool_usage))
451            .route("/api/genai/retry_stats", get(crate::api::genai::get_retry_stats))
452            .route("/api/genai/retrieval_stats", get(crate::api::genai::get_retrieval_stats))
453            .route("/api/genai/pricing_metadata", get(crate::api::genai::get_pricing_metadata))
454            .route("/api/genai/agent_framework_defs", get(crate::api::genai::get_agent_framework_defs))
455            .route("/api/genai/truncation_rate", get(crate::api::genai::get_truncation_rate))
456            .route("/api/genai/cache_hit_rate", get(crate::api::genai::get_cache_hit_rate))
457            .route("/api/genai/agent_roles", get(crate::api::genai::get_agent_roles))
458            .route(
459                "/api/genai/reasoning_share",
460                get(crate::api::genai::get_reasoning_share),
461            )
462            .route("/api/genai/agents", get(crate::api::genai::get_agents))
463            .route("/api/genai/projects", get(crate::api::genai::get_projects))
464            .route("/api/genai/provider_mix", get(crate::api::genai::get_provider_mix))
465            .route("/api/genai/request_param_profile", get(crate::api::genai::get_request_param_profile))
466            .route("/api/genai/conversation_depth", get(crate::api::genai::get_conversation_depth))
467            .route("/api/genai/latency_series", get(crate::api::genai::get_latency_series))
468            .route("/api/genai/calls_series", get(crate::api::genai::get_calls_series))
469            .route("/api/genai/latency_by_context", get(crate::api::genai::get_latency_by_context))
470            .route("/api/genai/error_types", get(crate::api::genai::get_error_types))
471            .route("/api/genai/model_drift", get(crate::api::genai::get_model_drift))
472            .route("/api/genai/tool_approvals", get(crate::api::genai::get_tool_approvals))
473            .route("/api/genai/stop_reasons", get(crate::api::genai::get_stop_reasons))
474            .route("/api/genai/context_type_split", get(crate::api::genai::get_context_type_split))
475            .route("/api/genai/tool_errors", get(crate::api::genai::get_tool_errors))
476            .route("/api/genai/hour_of_day", get(crate::api::genai::get_hour_of_day))
477            .route("/api/genai/effort_breakdown", get(crate::api::genai::get_effort_breakdown))
478            .route("/api/genai/efficiency_stats", get(crate::api::genai::get_efficiency_stats))
479            .route("/api/genai/codex_ttft", get(crate::api::genai::get_codex_ttft))
480            .route("/api/genai/project_rollup", get(crate::api::genai::get_agent_project_rollup))
481            .route("/api/genai/mcp_health", get(crate::api::genai::get_mcp_health))
482            .route("/api/genai/guardian_stats", get(crate::api::genai::get_guardian_stats))
483            .route("/api/genai/multi_agent_stats", get(crate::api::genai::get_multi_agent_stats))
484            .route("/api/genai/codex_turn_breakdown", get(crate::api::genai::get_codex_turn_breakdown))
485            .route("/api/genai/codex_idle_ratio", get(crate::api::genai::get_codex_idle_ratio))
486            .route("/api/genai/session_duration", get(crate::api::genai::get_session_duration))
487            .route("/api/genai/loc_efficiency", get(crate::api::genai::get_loc_efficiency))
488            .route(
489                "/api/genai/rare_tool_sessions",
490                get(crate::api::genai::get_rare_tool_sessions),
491            )
492            .route(
493                "/api/genai/human_response_latency",
494                get(crate::api::genai::get_human_response_latency),
495            )
496            .route("/api/genai/session_model_breakdown", get(crate::api::genai::get_session_model_breakdown))
497            .route("/api/genai/speed_distribution", get(crate::api::genai::get_speed_distribution))
498            .route("/api/genai/cross_tool_ttft", get(crate::api::genai::get_cross_tool_ttft))
499            .route("/api/genai/hook_overhead", get(crate::api::genai::get_hook_overhead))
500            .route("/api/genai/bob_hook_overhead", get(crate::api::genai::get_bob_hook_overhead))
501            .route("/api/genai/tool_switch_overhead", get(crate::api::genai::get_tool_switch_overhead))
502            .route("/api/genai/tool_failure_rates", get(crate::api::genai::get_tool_failure_rates))
503            .route("/api/genai/daily_tool_mix", get(crate::api::genai::get_daily_tool_mix))
504            .route("/api/genai/productivity", get(crate::api::genai::get_productivity))
505            .route("/api/genai/cost_projection", get(crate::api::genai::get_cost_projection))
506            .route("/api/genai/cost_by_project", get(crate::api::genai::get_cost_by_project))
507            .route("/api/genai/session_depth_cost", get(crate::api::genai::get_session_depth_cost))
508            .route("/api/genai/time_in_tool", get(crate::api::genai::get_time_in_tool))
509            .route("/api/genai/skill_activity", get(crate::api::genai::get_skill_activity))
510            .route("/api/genai/skill_outcomes", get(crate::api::genai::get_skill_outcomes))
511            .route("/api/genai/model_selection_heatmap", get(crate::api::genai::get_model_selection_heatmap))
512            .route("/api/genai/session_quality_summary", get(crate::api::genai::get_session_quality_summary))
513            .route("/api/genai/recent_errors", get(crate::api::genai::get_recent_errors))
514            // API routes - Sessions
515            .route("/api/sessions", get(crate::api::sessions::list_sessions))
516            .route("/api/sessions/costs", get(crate::api::sessions::get_session_costs))
517            .route(
518                "/api/sessions/cost-distribution",
519                get(crate::api::sessions::get_session_cost_distribution),
520            )
521            .route("/api/sessions/{session_id}/diagnose", get(crate::api::sessions::get_session_diagnose))
522            .route(
523                "/api/sessions/{session_id}/context",
524                get(crate::api::sessions::get_session_context),
525            )
526            .route("/api/sessions/chains", get(crate::api::sessions::get_session_chains))
527            // OpenAPI spec endpoint
528            .route("/api/openapi.json", get(|| async {
529                axum::Json(ApiDoc::openapi())
530            }))
531            // Add shared state
532            .with_state(self.state.clone());
533
534        api
535            // Short-TTL response cache + in-flight dedup for read-only
536            // endpoints (applies only to the whitelisted API routes below;
537            // static files and mutating endpoints pass through untouched)
538            .layer(axum::middleware::from_fn_with_state(
539                Arc::clone(&self.state.cache_state),
540                cache_handler,
541            ))
542            // Static file serving (index.html, CSS, JS)
543            .fallback(static_files::serve_static_file)
544            // Add tracing middleware
545            .layer(TraceLayer::new_for_http())
546    }
547
548    /// Start the dashboard server, serving until an error or a graceful
549    /// shutdown triggered through [`DashboardServer::shutdown_handle`].
550    pub async fn start(self) -> Result<(), Box<dyn std::error::Error>> {
551        let addr = self.config.bind_address;
552        let router = self.build_router();
553
554        info!("Starting dashboard server on {}", addr);
555
556        let listener = tokio::net::TcpListener::bind(addr).await?;
557        let shutdown_notify = Arc::clone(&self.shutdown_notify);
558        axum::serve(listener, router)
559            .with_graceful_shutdown(async move {
560                shutdown_notify.notified().await;
561                info!("Shutting down dashboard server");
562            })
563            .await?;
564
565        Ok(())
566    }
567}
568
569#[cfg(test)]
570mod server_shutdown_tests {
571    use super::*;
572    use otelite_storage::sqlite::SqliteBackend;
573    use otelite_storage::StorageConfig;
574    use tempfile::TempDir;
575
576    /// M16: `start()` must return promptly once the shutdown handle is
577    /// triggered (the signal path in `otelite serve`), instead of
578    /// blocking on `axum::serve` forever.
579    #[tokio::test]
580    async fn test_start_returns_on_shutdown_trigger() {
581        let temp = TempDir::new().expect("temp dir");
582        let config = StorageConfig::default().with_data_dir(temp.path().join("otelite.db"));
583        let mut storage = SqliteBackend::new(config);
584        storage.initialize().await.expect("storage initializes");
585        let storage: Arc<dyn StorageBackend> = Arc::new(storage);
586
587        let port = {
588            let l = tokio::net::TcpListener::bind("127.0.0.1:0")
589                .await
590                .expect("bind free port");
591            l.local_addr().expect("local addr").port()
592        };
593        let dashboard = DashboardServer::new(
594            DashboardConfig::default()
595                .with_bind_address(format!("127.0.0.1:{port}").parse().unwrap()),
596            storage,
597        );
598        let handle = dashboard.shutdown_handle();
599        let serve = tokio::spawn(async move { dashboard.start().await.map_err(|e| e.to_string()) });
600
601        // Give the server a moment to bind, then trigger the shutdown.
602        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
603        handle.trigger();
604
605        let result = tokio::time::timeout(std::time::Duration::from_secs(5), serve)
606            .await
607            .expect("start() must return after the shutdown trigger")
608            .expect("serve task must not panic");
609        result.expect("a clean shutdown is not a server error");
610    }
611}