1use crate::server::AppState;
4use axum::{
5 extract::{Path, Query, State},
6 http::StatusCode,
7 response::Json,
8};
9use chrono::{DateTime, Local, Utc};
10use otelite_core::api::{
11 ErrorResponse, SessionContextGrowth, SessionDiagnoseResponse, SessionInteraction,
12 SessionListResponse, SessionSummary,
13};
14use otelite_core::filters::{GenAiFilters, FILTER_DIMENSIONS};
15use otelite_core::query::{Operator, QueryPredicate, QueryValue};
16use otelite_core::storage::QueryParams;
17use otelite_core::telemetry::trace::StatusCode as SpanStatusCode;
18use otelite_core::telemetry::{extract_ttft_secs, GenAiSpanInfo, Span};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeSet, HashMap, HashSet};
21
22fn root_llm_span(spans: &[Span]) -> Option<&Span> {
23 spans
24 .iter()
25 .filter(|s| s.parent_span_id.is_none())
26 .find(|s| s.attributes.keys().any(|k| k.starts_with("gen_ai.")))
27 .or_else(|| {
28 spans
29 .iter()
30 .find(|s| s.attributes.keys().any(|k| k.starts_with("gen_ai.")))
31 })
32}
33
34pub async fn get_session_diagnose(
39 State(state): State<AppState>,
40 Path(session_id): Path<String>,
41) -> Result<Json<SessionDiagnoseResponse>, (StatusCode, Json<ErrorResponse>)> {
42 let query = QueryParams {
45 predicates: vec![QueryPredicate {
46 field: "session.id".to_string(),
47 operator: Operator::Equal,
48 value: QueryValue::String(session_id.clone()),
49 }],
50 limit: Some(10_000),
51 ..Default::default()
52 };
53
54 let all_spans = state.storage.query_spans(&query).await.map_err(|e| {
55 (
56 StatusCode::INTERNAL_SERVER_ERROR,
57 Json(ErrorResponse::internal_error(e.to_string())),
58 )
59 })?;
60
61 if all_spans.is_empty() {
62 return Err((
63 StatusCode::NOT_FOUND,
64 Json(ErrorResponse::not_found(format!("session {}", session_id))),
65 ));
66 }
67
68 let mut by_trace: HashMap<String, Vec<Span>> = HashMap::new();
70 for span in all_spans {
71 by_trace
72 .entry(span.trace_id.clone())
73 .or_default()
74 .push(span);
75 }
76
77 let mut trace_groups: Vec<(String, Vec<Span>)> = by_trace.into_iter().collect();
79 trace_groups.sort_by_key(|(_, spans)| spans.iter().map(|s| s.start_time).min().unwrap_or(0));
80
81 let mut interactions: Vec<SessionInteraction> = Vec::new();
82
83 for (idx, (trace_id, spans)) in trace_groups.iter().enumerate() {
84 let root = match root_llm_span(spans) {
85 Some(s) => s,
86 None => continue,
87 };
88
89 let mut genai = GenAiSpanInfo::from_attributes(&root.attributes);
90 let mut ttft = extract_ttft_secs(&root.attributes);
91 let duration_ms = (root.end_time - root.start_time) / 1_000_000;
92 let is_error = root.status.code == SpanStatusCode::Error;
93 let is_stall = is_error && ttft.is_some() && duration_ms > 30_000;
94
95 if genai.input_tokens.is_none() && genai.output_tokens.is_none() {
100 let llm_spans: Vec<&Span> = spans
101 .iter()
102 .filter(|s| {
103 s.name.contains("llm_request")
104 || s.attributes.keys().any(|k| k.starts_with("gen_ai."))
105 })
106 .collect();
107
108 if !llm_spans.is_empty() {
109 let first = llm_spans[0];
111 let first_genai = GenAiSpanInfo::from_attributes(&first.attributes);
112 if genai.model.is_none() {
113 genai.model = first_genai.model;
114 }
115 if genai.response_id.is_none() {
116 genai.response_id = first_genai.response_id;
117 }
118 if ttft.is_none() {
119 ttft = extract_ttft_secs(&first.attributes);
120 }
121
122 let mut total_input: u64 = 0;
125 let mut total_output: u64 = 0;
126 let mut total_cache_read: u64 = 0;
127 let mut total_cache_creation: u64 = 0;
128 for s in &llm_spans {
129 let sg = GenAiSpanInfo::from_attributes(&s.attributes);
130 total_input += sg.input_tokens.unwrap_or(0);
131 total_output += sg.output_tokens.unwrap_or(0);
132 total_cache_read += sg.cache_read_tokens.unwrap_or(0);
133 total_cache_creation += sg.cache_creation_tokens.unwrap_or(0);
134 }
135 if total_input > 0 || total_output > 0 {
136 genai.input_tokens = Some(total_input);
137 genai.output_tokens = Some(total_output);
138 }
139 if total_cache_read > 0 {
140 genai.cache_read_tokens = Some(total_cache_read);
141 }
142 if total_cache_creation > 0 {
143 genai.cache_creation_tokens = Some(total_cache_creation);
144 }
145 }
146 }
147
148 let dt = DateTime::<Utc>::from_timestamp_nanos(root.start_time);
149 let time_str = dt.with_timezone(&Local).format("%H:%M:%S").to_string();
150
151 let (body_length, prompt_id) = if is_error {
154 let log_params = QueryParams {
155 trace_id: Some(trace_id.clone()),
156 search_text: Some("api_request_body".to_string()),
157 limit: Some(1),
158 ..Default::default()
159 };
160 let log_body_len = state
161 .storage
162 .query_logs(&log_params)
163 .await
164 .ok()
165 .and_then(|logs| logs.into_iter().next())
166 .and_then(|log| {
167 log.attributes
168 .get("body_length")
169 .and_then(|v| v.parse::<u64>().ok())
170 });
171 let pid = root.attributes.get("prompt.id").cloned();
172 (log_body_len, pid)
173 } else {
174 (None, None)
175 };
176
177 interactions.push(SessionInteraction {
178 index: idx + 1,
179 time: time_str,
180 model: genai
181 .model
182 .clone()
183 .or_else(|| root.attributes.get("gen_ai.request.model").cloned()),
184 input_tokens: genai.input_tokens,
185 output_tokens: genai.output_tokens,
186 cache_read_tokens: genai.cache_read_tokens,
187 cache_creation_tokens: genai.cache_creation_tokens,
188 ttft_secs: ttft,
189 duration_ms,
190 is_error,
191 is_stall,
192 response_id: genai.response_id.clone(),
193 trace_id: trace_id.clone(),
194 start_time_ns: root.start_time,
195 body_length,
196 prompt_id,
197 });
198 }
199
200 if interactions.is_empty() {
201 return Err((
202 StatusCode::NOT_FOUND,
203 Json(ErrorResponse::not_found(format!(
204 "GenAI spans for session {}",
205 session_id
206 ))),
207 ));
208 }
209
210 let models: Vec<String> = interactions
211 .iter()
212 .filter_map(|i| i.model.clone())
213 .collect::<HashSet<_>>()
214 .into_iter()
215 .collect();
216
217 let first_ts = interactions.first().map(|i| i.start_time_ns).unwrap_or(0);
218 let last_ts = interactions.last().map(|i| i.start_time_ns).unwrap_or(0);
219 let start_time = DateTime::<Utc>::from_timestamp_nanos(first_ts)
220 .format("%Y-%m-%dT%H:%M:%SZ")
221 .to_string();
222 let end_time = DateTime::<Utc>::from_timestamp_nanos(last_ts)
223 .format("%Y-%m-%dT%H:%M:%SZ")
224 .to_string();
225
226 let error_count = interactions.iter().filter(|i| i.is_error).count();
227 let stall_count = interactions.iter().filter(|i| i.is_stall).count();
228
229 let input_series: Vec<u64> = interactions.iter().filter_map(|i| i.input_tokens).collect();
230 let context_growth = if input_series.len() >= 2 {
231 Some(SessionContextGrowth {
232 first_tokens: *input_series.first().unwrap(),
233 last_tokens: *input_series.last().unwrap(),
234 peak_tokens: *input_series.iter().max().unwrap(),
235 interaction_count: interactions.len(),
236 })
237 } else {
238 None
239 };
240
241 Ok(Json(SessionDiagnoseResponse {
242 session_id,
243 models,
244 start_time,
245 end_time,
246 total_interactions: interactions.len(),
247 error_count,
248 stall_count,
249 interactions,
250 context_growth,
251 }))
252}
253
254#[derive(Debug, Deserialize, Default)]
255pub struct SessionListQuery {
256 pub start_time: Option<i64>,
257 pub end_time: Option<i64>,
258 pub limit: Option<usize>,
259 pub agent: Option<String>,
261 pub model: Option<String>,
263 pub provider: Option<String>,
265 pub project: Option<String>,
267 pub session: Option<String>,
269}
270
271pub async fn list_sessions(
279 State(state): State<AppState>,
280 Query(params): Query<SessionListQuery>,
281) -> Result<Json<SessionListResponse>, (StatusCode, Json<ErrorResponse>)> {
282 let limit = params.limit.unwrap_or(200);
283
284 let query = QueryParams {
288 start_time: params.start_time,
289 end_time: params.end_time,
290 limit: Some(20_000),
291 ..Default::default()
292 };
293
294 let all_spans = state.storage.query_spans(&query).await.map_err(|e| {
295 (
296 StatusCode::INTERNAL_SERVER_ERROR,
297 Json(ErrorResponse::internal_error(e.to_string())),
298 )
299 })?;
300
301 let mut by_session: HashMap<String, HashMap<String, Vec<Span>>> = HashMap::new();
303 for span in all_spans {
304 let sid = match span.attributes.get("session.id") {
305 Some(s) if !s.is_empty() => s.clone(),
306 _ => continue,
307 };
308 by_session
309 .entry(sid)
310 .or_default()
311 .entry(span.trace_id.clone())
312 .or_default()
313 .push(span);
314 }
315
316 let mut summaries: Vec<SessionSummary> = Vec::with_capacity(by_session.len());
317
318 for (session_id, by_trace) in by_session {
319 let mut models: BTreeSet<String> = BTreeSet::new();
320 let mut projects: BTreeSet<String> = BTreeSet::new();
321 let mut providers: BTreeSet<String> = BTreeSet::new();
322 let mut families: BTreeSet<String> = BTreeSet::new();
323 let mut interaction_count = 0usize;
324 let mut total_input: u64 = 0;
325 let mut total_output: u64 = 0;
326 let mut error_count = 0usize;
327 let mut first_seen_ns = i64::MAX;
328 let mut last_seen_ns = i64::MIN;
329
330 for spans in by_trace.values() {
336 for s in spans {
337 if let Some(p) = s.attributes.get("project.id") {
338 if !p.is_empty() {
339 projects.insert(p.clone());
340 }
341 }
342 for key in ["gen_ai.system", "gen_ai.provider.name", "llm.provider"] {
343 if let Some(v) = s.attributes.get(key) {
344 if !v.is_empty() {
345 providers.insert(v.clone());
346 }
347 }
348 }
349 let scope = s
350 .attributes
351 .get("otel.scope.name")
352 .cloned()
353 .unwrap_or_default();
354 if s.name.starts_with("claude_code.")
355 || scope.starts_with("com.anthropic.claude_code")
356 {
357 families.insert("claude".to_string());
358 }
359 if s.name.starts_with("opencode.") || scope == "com.opencode" {
360 families.insert("opencode".to_string());
361 }
362 if scope == "codex_cli_rs" {
363 families.insert("codex".to_string());
364 }
365 }
366 }
367
368 for (_trace_id, spans) in by_trace {
369 let root = match root_llm_span(&spans) {
370 Some(s) => s,
371 None => continue,
372 };
373 interaction_count += 1;
374 let mut genai = GenAiSpanInfo::from_attributes(&root.attributes);
375
376 if genai.input_tokens.is_none() && genai.output_tokens.is_none() {
379 let llm_spans: Vec<&Span> = spans
380 .iter()
381 .filter(|s| {
382 s.name.contains("llm_request")
383 || s.attributes.keys().any(|k| k.starts_with("gen_ai."))
384 })
385 .collect();
386 if !llm_spans.is_empty() {
387 let first_genai = GenAiSpanInfo::from_attributes(&llm_spans[0].attributes);
388 if genai.model.is_none() {
389 genai.model = first_genai.model;
390 }
391 let mut t_in: u64 = 0;
392 let mut t_out: u64 = 0;
393 for s in &llm_spans {
394 let sg = GenAiSpanInfo::from_attributes(&s.attributes);
395 t_in += sg.input_tokens.unwrap_or(0);
396 t_out += sg.output_tokens.unwrap_or(0);
397 }
398 if t_in > 0 || t_out > 0 {
399 genai.input_tokens = Some(t_in);
400 genai.output_tokens = Some(t_out);
401 }
402 }
403 }
404
405 if let Some(m) = genai.model.as_deref().or_else(|| {
406 root.attributes
407 .get("gen_ai.request.model")
408 .map(|s| s.as_str())
409 }) {
410 models.insert(m.to_string());
411 }
412 if let Some(v) = genai.input_tokens {
413 total_input += v;
414 }
415 if let Some(v) = genai.output_tokens {
416 total_output += v;
417 }
418 if root.status.code == SpanStatusCode::Error {
419 error_count += 1;
420 }
421 if root.start_time < first_seen_ns {
422 first_seen_ns = root.start_time;
423 }
424 if root.start_time > last_seen_ns {
425 last_seen_ns = root.start_time;
426 }
427 }
428
429 if interaction_count == 0 {
430 continue;
431 }
432
433 summaries.push(SessionSummary {
434 session_id,
435 models: models.into_iter().collect(),
436 projects: projects.into_iter().collect(),
437 providers: providers.into_iter().collect(),
438 agent_families: families.into_iter().collect(),
439 interaction_count,
440 total_input_tokens: total_input,
441 total_output_tokens: total_output,
442 error_count,
443 first_seen_ns,
444 last_seen_ns,
445 });
446 }
447
448 if let Some(ref agent) = params.agent {
451 summaries.retain(|s| s.agent_families.iter().any(|f| f == agent));
452 }
453 if let Some(ref model) = params.model {
454 summaries.retain(|s| s.models.iter().any(|m| m == model));
455 }
456 if let Some(ref provider) = params.provider {
457 summaries.retain(|s| s.providers.iter().any(|p| p == provider));
458 }
459 if let Some(ref project) = params.project {
460 summaries.retain(|s| s.projects.iter().any(|p| p == project));
461 }
462 if let Some(ref session) = params.session {
463 summaries.retain(|s| s.session_id == *session);
464 }
465
466 summaries.sort_by_key(|s| std::cmp::Reverse(s.last_seen_ns));
468 let total = summaries.len();
469 summaries.truncate(limit);
470
471 let filters = GenAiFilters {
472 agent: params.agent.clone(),
473 model: params.model.clone(),
474 models: None,
475 provider: params.provider.clone(),
476 project: params.project.clone(),
477 session: params.session.clone(),
478 };
479
480 Ok(Json(SessionListResponse {
481 sessions: summaries,
482 total,
483 filters_applied: filters.applied(&FILTER_DIMENSIONS),
484 }))
485}
486
487#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
489pub struct SessionCostsQuery {
490 pub start_time: Option<i64>,
491 pub end_time: Option<i64>,
492 pub limit: Option<usize>,
495}
496
497#[utoipa::path(
506 get,
507 path = "/api/sessions/costs",
508 params(SessionCostsQuery),
509 responses(
510 (status = 200, description = "Per-session costs, sorted by cost descending", body = otelite_core::api::SessionCostResponse),
511 (status = 500, description = "Internal server error", body = ErrorResponse)
512 )
513)]
514pub async fn get_session_costs(
515 State(state): State<AppState>,
516 Query(params): Query<SessionCostsQuery>,
517) -> Result<Json<otelite_core::api::SessionCostResponse>, (StatusCode, Json<ErrorResponse>)> {
518 use otelite_core::session_cost;
519
520 let limit = params.limit.unwrap_or(50).min(500);
521
522 let (rows, quality_map) = tokio::try_join!(
523 state
524 .storage
525 .query_session_costs(params.start_time, params.end_time),
526 state
527 .storage
528 .query_session_quality_map(params.start_time, params.end_time),
529 )
530 .map_err(|e| {
531 (
532 StatusCode::INTERNAL_SERVER_ERROR,
533 Json(ErrorResponse::storage_error(format!(
534 "query session costs: {e}"
535 ))),
536 )
537 })?;
538
539 let pricing = state.pricing.snapshot().await;
540 let mut sessions =
541 session_cost::build_session_costs_with_quality(rows, &pricing.db, &quality_map);
542 let median = session_cost::apply_anomaly_flags(&mut sessions).map(|(m, _)| m);
543 sessions.truncate(limit);
544
545 Ok(Json(otelite_core::api::SessionCostResponse {
546 sessions,
547 median_cost_usd: median,
548 anomaly_rule: session_cost::ANOMALY_RULE.to_string(),
549 filters_applied: Vec::new(),
552 }))
553}
554
555#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
557pub struct SessionCostDistributionQuery {
558 pub start_time: Option<i64>,
559 pub end_time: Option<i64>,
560 pub buckets: Option<usize>,
562}
563
564#[utoipa::path(
570 get,
571 path = "/api/sessions/cost-distribution",
572 params(SessionCostDistributionQuery),
573 responses(
574 (status = 200, description = "Log-spaced per-session cost distribution", body = otelite_core::api::CostDistributionResponse),
575 (status = 500, description = "Internal server error", body = ErrorResponse)
576 )
577)]
578pub async fn get_session_cost_distribution(
579 State(state): State<AppState>,
580 Query(params): Query<SessionCostDistributionQuery>,
581) -> Result<Json<otelite_core::api::CostDistributionResponse>, (StatusCode, Json<ErrorResponse>)> {
582 use otelite_core::session_cost;
583
584 let buckets = params.buckets.unwrap_or(20).min(100);
585
586 let rows = state
587 .storage
588 .query_session_costs(params.start_time, params.end_time)
589 .await
590 .map_err(|e| {
591 (
592 StatusCode::INTERNAL_SERVER_ERROR,
593 Json(ErrorResponse::storage_error(format!(
594 "query session cost distribution: {e}"
595 ))),
596 )
597 })?;
598
599 let pricing = state.pricing.snapshot().await;
600 let sessions = session_cost::build_session_costs(rows, &pricing.db);
601
602 Ok(Json(session_cost::build_cost_distribution(
603 &sessions, buckets,
604 )))
605}
606
607#[utoipa::path(
614 get,
615 path = "/api/sessions/{session_id}/context",
616 params(
617 ("session_id" = String, Path, description = "Session id"),
618 SessionContextQuery,
619 ),
620 responses(
621 (status = 200, description = "Session context", body = otelite_core::api::SessionContextResponse),
622 (status = 400, description = "Invalid query parameters"),
623 (status = 404, description = "No data for this session", body = ErrorResponse),
624 (status = 500, description = "Internal error", body = ErrorResponse),
625 )
626)]
627pub async fn get_session_context(
628 State(state): State<AppState>,
629 Path(session_id): Path<String>,
630 Query(params): Query<SessionContextQuery>,
631) -> Result<Json<otelite_core::api::SessionContextResponse>, (StatusCode, Json<ErrorResponse>)> {
632 let limit = params.limit.unwrap_or(500).min(5000) as u64;
635 let resp = state
636 .storage
637 .query_session_context(&session_id, params.start_time, params.end_time, limit)
638 .await
639 .map_err(|e| {
640 (
641 StatusCode::INTERNAL_SERVER_ERROR,
642 Json(ErrorResponse::storage_error(format!(
643 "query session context: {e}"
644 ))),
645 )
646 })?
647 .ok_or_else(|| {
648 (
649 StatusCode::NOT_FOUND,
650 Json(ErrorResponse::not_found(format!("session {}", session_id))),
651 )
652 })?;
653
654 Ok(Json(resp))
655}
656
657#[derive(Debug, Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
658pub struct SessionContextQuery {
659 pub start_time: Option<i64>,
661 pub end_time: Option<i64>,
663 pub limit: Option<usize>,
665}
666
667#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
669pub struct SessionChainsQuery {
670 pub start_time: Option<i64>,
672 pub end_time: Option<i64>,
674 pub chain_window_secs: Option<u64>,
677}
678
679#[utoipa::path(
688 get,
689 path = "/api/sessions/chains",
690 params(SessionChainsQuery),
691 responses(
692 (status = 200, description = "Session chains sorted by total tokens descending", body = otelite_core::api::SessionChainsResponse),
693 (status = 400, description = "Invalid chain_window_secs", body = ErrorResponse),
694 (status = 500, description = "Internal server error", body = ErrorResponse)
695 )
696)]
697pub async fn get_session_chains(
698 State(state): State<AppState>,
699 Query(params): Query<SessionChainsQuery>,
700) -> Result<Json<otelite_core::api::SessionChainsResponse>, (StatusCode, Json<ErrorResponse>)> {
701 use otelite_core::pricing::TokenUsage;
702 use otelite_core::session_chain;
703
704 const DEFAULT_CHAIN_WINDOW_SECS: u64 = 7200;
705 const MAX_CHAIN_WINDOW_SECS: u64 = 604_800; let window_secs = params
707 .chain_window_secs
708 .unwrap_or(DEFAULT_CHAIN_WINDOW_SECS);
709 if window_secs == 0 || window_secs > MAX_CHAIN_WINDOW_SECS {
710 return Err((
711 StatusCode::BAD_REQUEST,
712 Json(ErrorResponse::bad_request(format!(
713 "chain_window_secs must be between 1 and {MAX_CHAIN_WINDOW_SECS} seconds, got {window_secs}"
714 ))),
715 ));
716 }
717
718 let resp = state
719 .storage
720 .query_session_chain_storage(params.start_time, params.end_time)
721 .await
722 .map_err(|e| {
723 (
724 StatusCode::INTERNAL_SERVER_ERROR,
725 Json(ErrorResponse::storage_error(format!(
726 "query session_chains: {e}"
727 ))),
728 )
729 })?;
730
731 let pricing = state.pricing.snapshot().await;
735 let mut per_session_model: HashMap<(String, String), TokenUsage> = HashMap::new();
736 for r in &resp.rows {
737 let e = per_session_model
738 .entry((r.session_id.clone(), r.model.clone()))
739 .or_insert(TokenUsage {
740 input: 0,
741 output: 0,
742 cache_creation: 0,
743 cache_read: 0,
744 });
745 e.input += r.input_tokens;
746 e.output += r.output_tokens;
747 e.cache_creation += r.cache_creation_tokens;
748 e.cache_read += r.cache_read_tokens;
749 }
750 let mut costs: HashMap<String, Option<f64>> = HashMap::new();
751 for ((session_id, model), usage) in per_session_model {
752 let cr = pricing.db.compute_cost(Some(&model), usage, None);
753 let entry = costs.entry(session_id).or_insert(None);
754 if let Some(c) = cr.cost {
755 *entry = Some(entry.unwrap_or(0.0) + c);
756 }
757 }
758
759 Ok(Json(session_chain::build_chains(
760 &resp.rows,
761 &costs,
762 window_secs,
763 )))
764}