Skip to main content

otelite_api/api/
sessions.rs

1//! Session-level API endpoints.
2
3use 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
34/// GET /api/sessions/:session_id/diagnose
35///
36/// Returns a forensic report for an LLM session: per-interaction token counts,
37/// latency, errors, streaming stalls, and context growth.
38pub async fn get_session_diagnose(
39    State(state): State<AppState>,
40    Path(session_id): Path<String>,
41) -> Result<Json<SessionDiagnoseResponse>, (StatusCode, Json<ErrorResponse>)> {
42    // Single query: all spans where session.id = <session_id>.
43    // query_spans applies predicates via json_extract — no per-trace round-trips needed.
44    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    // Group spans by trace_id.
69    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    // Sort trace groups by the earliest span start_time (chronological order).
78    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        // Claude Code traces use claude_code.interaction as the root span with
96        // claude_code.llm_request children. When the root span carries no token
97        // counts, aggregate across all LLM child spans so the modal displays
98        // real numbers instead of blanks.
99        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                // Use the first LLM span for scalar fields (model, TTFT, response_id).
110                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                // Sum token counts across all LLM spans in the trace (one
123                // trace can contain multiple tool-call round-trips).
124                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        // For errored interactions, fetch the api_request_body log to get body_length.
152        // prompt.id is available directly on the span attributes.
153        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    /// Agent family filter: `claude`, `opencode`, or `codex`
260    pub agent: Option<String>,
261    /// Model name filter
262    pub model: Option<String>,
263    /// Provider filter
264    pub provider: Option<String>,
265    /// Project id filter (opencode data only)
266    pub project: Option<String>,
267    /// Session id filter
268    pub session: Option<String>,
269}
270
271/// GET /api/sessions
272///
273/// Lists distinct GenAI sessions seen in the time window with summary stats:
274/// model(s), interaction count, total tokens, error count, first/last seen.
275///
276/// Strategy: single `query_spans` over the window for any span carrying
277/// `session.id`, group by session.id in memory, aggregate.
278pub 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    // No "exists" predicate available; scan all spans in the window and
285    // filter in memory. Limit caps the worst case at 20k spans (typically
286    // many fewer once a time window is applied).
287    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    // Group by session.id, then by trace_id (one interaction = one trace).
302    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        // Filter-bar dimensions collected from the session's spans:
331        // opencode carries project.id; provider is gen_ai.system (claude)
332        // or gen_ai.provider.name / llm.provider (opencode); the agent
333        // family is derived from span name / scope exactly like the
334        // storage-layer filter predicates.
335        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            // Claude Code: root span (claude_code.interaction) carries no token
377            // counts — aggregate across child LLM spans, same as diagnose does.
378            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    // Apply the global filter bar in memory (#135): the list is built from
449    // span attributes, so all five dimensions are addressable.
450    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    // Sort newest-first by last_seen, then truncate.
467    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/// Query parameters for GET /api/sessions/costs.
488#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
489pub struct SessionCostsQuery {
490    pub start_time: Option<i64>,
491    pub end_time: Option<i64>,
492    /// Max sessions to return (default 50, cap 500). The anomaly flag is
493    /// computed over the full window before truncation.
494    pub limit: Option<usize>,
495}
496
497/// GET /api/sessions/costs
498///
499/// Per-session cost, tokens and duration for opencode and claude sessions
500/// in the window, sorted by cost descending. opencode's cost is its own
501/// cumulative session-cost counter ("actual"); claude is estimated from
502/// span token counts x pricing ("estimated") because its cost counter
503/// under-reports. A session is anomalous when its cost exceeds three times
504/// the median session cost (formula in `anomaly_rule`).
505#[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        // Per-session costs come from opencode counters + claude token
550        // pricing; the bar's dimensions aren't addressable across both.
551        filters_applied: Vec::new(),
552    }))
553}
554
555/// Query parameters for GET /api/sessions/cost-distribution.
556#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
557pub struct SessionCostDistributionQuery {
558    pub start_time: Option<i64>,
559    pub end_time: Option<i64>,
560    /// Number of log-spaced buckets (default 20, cap 100).
561    pub buckets: Option<usize>,
562}
563
564/// GET /api/sessions/cost-distribution
565///
566/// Log-spaced histogram of per-session costs over the window. Bucket 0
567/// covers zero-cost sessions; the remaining buckets span equal decades up
568/// to the most expensive session.
569#[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/// GET /api/sessions/:session_id/context
608///
609/// Everything observed for one session on one timeline (issue #134):
610/// spans and logs truncated to `limit` (true counts in `*_total`) and
611/// per-name metric aggregates. 404 when the session has no data in any
612/// of the three stores.
613#[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    // No "exists" check needed: the storage layer returns None when the
633    // session has no data in spans, logs or metrics.
634    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    /// Start time (nanoseconds since Unix epoch)
660    pub start_time: Option<i64>,
661    /// End time (nanoseconds since Unix epoch)
662    pub end_time: Option<i64>,
663    /// Max spans and logs to return (default 500, cap 5000)
664    pub limit: Option<usize>,
665}
666
667/// Query parameters for GET /api/sessions/chains.
668#[derive(Debug, Deserialize, Serialize, utoipa::IntoParams, utoipa::ToSchema)]
669pub struct SessionChainsQuery {
670    /// Start time (nanoseconds since Unix epoch)
671    pub start_time: Option<i64>,
672    /// End time (nanoseconds since Unix epoch)
673    pub end_time: Option<i64>,
674    /// Chain window in seconds: a gap strictly greater than this splits
675    /// a session into two segments (default 7200 = 2 h).
676    pub chain_window_secs: Option<u64>,
677}
678
679/// GET /api/sessions/chains
680///
681/// Session chains (#165): every session in the window as a work thread.
682/// A resumed session keeps its stable session ID across the
683/// resumption (e.g. Claude Code's `--continue`), so each session's
684/// activity is split into segments at gaps beyond the window; segments
685/// greater than one mark a resumed thread. Costs are priced per
686/// (session, model) — a chain with no pricable span carries `null`.
687#[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; // one week
706    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    // Price per (session, model), then fold to per-session totals — the
732    // same convention as session depth vs cost (#180). Unpriced models
733    // leave the session unpriced (null, never zero).
734    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}