1use super::activity::activity;
4use super::types::*;
5use crate::core::event::{SessionRecord, SessionStatus};
6use crate::store::Store;
7use anyhow::{Result, ensure};
8use serde::{Deserialize, Serialize};
9
10const ACTIVE_TTL_MS: u64 = 5 * 60_000;
11const ORPHAN_TTL_MS: u64 = 30 * 60_000;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
14pub struct VisualizationLimits {
15 pub sessions: usize,
17 pub selected_events: usize,
19 pub selected_spans: usize,
21 pub selected_files: usize,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct VisualizationQuery {
27 pub workspace: String,
28 pub selected_session_id: Option<String>,
29 pub now_ms: u64,
30 pub include_activity: bool,
32 pub select_latest: bool,
34 pub limits: VisualizationLimits,
35}
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub(crate) struct MaterializedRows {
39 pub(crate) sessions: usize,
40 pub(crate) selected_events: usize,
41 pub(crate) selected_spans: usize,
42 pub(crate) selected_files: usize,
43}
44
45pub(crate) struct BuiltReport {
46 pub(crate) report: VisualizationReport,
47 pub(crate) materialized: MaterializedRows,
48}
49
50pub fn build_report(store: &Store, query: VisualizationQuery) -> Result<VisualizationReport> {
51 Ok(build_report_observed(store, query)?.report)
52}
53
54pub(crate) fn build_report_observed(
55 store: &Store,
56 query: VisualizationQuery,
57) -> Result<BuiltReport> {
58 validate(&query.limits)?;
59 let active_since_ms = query.now_ms.saturating_sub(ACTIVE_TTL_MS);
60 let (totals, quality) = store.visualization_totals(&query.workspace, active_since_ms)?;
61 let sessions =
62 store.visualization_sessions(&query.workspace, query.limits.sessions, query.now_ms)?;
63 let selected = selected_detail(store, &query, sessions.first())?;
64 let activity = activity_report(store, &query)?;
65 let materialized = counts(&sessions, &selected);
66 Ok(BuiltReport {
67 report: report(query, totals, quality, sessions, selected, activity),
68 materialized,
69 })
70}
71
72fn validate(limits: &VisualizationLimits) -> Result<()> {
73 ensure_positive(limits.sessions, "session")?;
74 ensure_positive(limits.selected_events, "event")?;
75 ensure_positive(limits.selected_spans, "span")?;
76 ensure_positive(limits.selected_files, "file")?;
77 Ok(())
78}
79
80fn ensure_positive(limit: usize, kind: &str) -> Result<()> {
81 ensure!(limit > 0, "visualization {kind} limit must be positive");
82 Ok(())
83}
84
85fn activity_report(store: &Store, query: &VisualizationQuery) -> Result<ActivityReport> {
86 if query.include_activity {
87 activity(store, &query.workspace, query.now_ms)
88 } else {
89 Ok(Default::default())
90 }
91}
92
93fn selected_detail(
94 store: &Store,
95 query: &VisualizationQuery,
96 latest: Option<&TraceSummary>,
97) -> Result<Option<TraceDetail>> {
98 let Some(session) = selected_session(store, query, latest)? else {
99 return Ok(None);
100 };
101 let id = session.id.clone();
102 Ok(Some(TraceDetail {
103 session,
104 prompt: None,
105 events: store.list_latest_events_for_session(&id, query.limits.selected_events)?,
106 spans: store.limited_session_span_tree(&id, query.limits.selected_spans)?,
107 files: store.limited_files_for_session(&id, query.limits.selected_files)?,
108 }))
109}
110
111fn selected_session(
112 store: &Store,
113 query: &VisualizationQuery,
114 latest: Option<&TraceSummary>,
115) -> Result<Option<SessionRecord>> {
116 if let Some(session) = requested_session(store, query)? {
117 return Ok(Some(session));
118 }
119 if !query.select_latest {
120 return Ok(None);
121 }
122 latest
123 .map(|summary| store.get_session(&summary.id))
124 .transpose()
125 .map(Option::flatten)
126}
127
128fn requested_session(store: &Store, query: &VisualizationQuery) -> Result<Option<SessionRecord>> {
129 let Some(id) = query.selected_session_id.as_deref() else {
130 return Ok(None);
131 };
132 Ok(store
133 .get_session(id)?
134 .filter(|session| session.workspace == query.workspace))
135}
136
137fn counts(sessions: &[TraceSummary], selected: &Option<TraceDetail>) -> MaterializedRows {
138 MaterializedRows {
139 sessions: sessions.len(),
140 selected_events: selected.as_ref().map_or(0, |detail| detail.events.len()),
141 selected_spans: selected
142 .as_ref()
143 .map_or(0, |detail| span_count(&detail.spans)),
144 selected_files: selected.as_ref().map_or(0, |detail| detail.files.len()),
145 }
146}
147
148fn span_count(spans: &[crate::store::SpanNode]) -> usize {
149 spans
150 .iter()
151 .map(|node| 1 + span_count(&node.children))
152 .sum()
153}
154
155fn report(
156 query: VisualizationQuery,
157 totals: VisualizationTotals,
158 quality: DataQuality,
159 sessions: Vec<TraceSummary>,
160 selected: Option<TraceDetail>,
161 activity: ActivityReport,
162) -> VisualizationReport {
163 VisualizationReport {
164 generated_at_ms: query.now_ms,
165 workspace: query.workspace,
166 totals,
167 activity,
168 sessions,
169 selected,
170 quality,
171 }
172}
173
174pub(crate) fn derive_status(
175 session: &SessionRecord,
176 last_event_ms: Option<u64>,
177 error_count: u64,
178 now_ms: u64,
179) -> (DerivedStatus, String) {
180 if error_count > 0 {
181 return (DerivedStatus::Errored, "error event".into());
182 }
183 if session.status == SessionStatus::Done || session.ended_at_ms.is_some() {
184 return (DerivedStatus::Done, "session ended".into());
185 }
186 match last_event_ms {
187 Some(ts) if now_ms.saturating_sub(ts) <= ACTIVE_TTL_MS => {
188 (DerivedStatus::Active, "recent event".into())
189 }
190 Some(ts) if now_ms.saturating_sub(ts) >= ORPHAN_TTL_MS => {
191 (DerivedStatus::Orphaned, "stale open session".into())
192 }
193 Some(_) => (DerivedStatus::Idle, "no recent event".into()),
194 None => (DerivedStatus::Idle, "no events".into()),
195 }
196}