use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, ToSchema)]
pub enum RangeWindow {
#[serde(rename = "24h")]
H24,
#[serde(rename = "7d")]
D7,
#[default]
#[serde(rename = "30d")]
D30,
#[serde(rename = "all")]
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum OrderBy {
#[default]
LastActivityAt,
StartedAt,
EndedAt,
CostUsd,
TokensIn,
TokensOut,
}
impl OrderBy {
pub fn as_column(self) -> &'static str {
match self {
Self::LastActivityAt => "last_activity_at",
Self::StartedAt => "started_at",
Self::EndedAt => "ended_at",
Self::CostUsd => "cost_usd",
Self::TokensIn => "tokens_in",
Self::TokensOut => "tokens_out",
}
}
}
impl RangeWindow {
pub fn as_wire_str(self) -> &'static str {
match self {
Self::H24 => "24h",
Self::D7 => "7d",
Self::D30 => "30d",
Self::All => "all",
}
}
}
fn default_limit() -> u32 {
50
}
fn default_descending() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ListSessionsQuery {
#[serde(default)]
pub range: RangeWindow,
#[serde(default)]
pub status: Option<String>,
#[serde(default = "default_limit")]
pub limit: u32,
#[serde(default)]
pub offset: u32,
#[serde(default)]
pub order_by: OrderBy,
#[serde(default = "default_descending")]
pub descending: bool,
}
#[derive(Debug, Clone, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct StatsQuery {
#[serde(default)]
pub range: RangeWindow,
}
#[derive(Debug, Clone, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct CostByModelQuery {
#[serde(default)]
pub range: RangeWindow,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SessionListResponseDTO {
pub sessions: Vec<SessionRowDTO>,
pub total: i64,
pub limit: u32,
pub offset: u32,
pub has_more: bool,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SessionRowDTO {
pub session_id: String,
pub user_id: String,
pub dataset_id: Option<String>,
pub status: String,
#[serde(with = "crate::dto::util::iso8601_offset")]
pub started_at: chrono::DateTime<chrono::Utc>,
#[serde(with = "crate::dto::util::iso8601_offset")]
pub last_activity_at: chrono::DateTime<chrono::Utc>,
#[serde(with = "crate::dto::util::iso8601_offset_option", default)]
pub ended_at: Option<chrono::DateTime<chrono::Utc>>,
pub tokens_in: i32,
pub tokens_out: i32,
pub cost_usd: f64,
pub error_count: i32,
pub last_model: Option<String>,
pub effective_status: String,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SessionStatsDTO {
pub range: String,
pub sessions: i64,
pub total_spend_usd: f64,
pub avg_spend_per_session_usd: f64,
pub tokens_in: i64,
pub tokens_out: i64,
pub tokens_total: i64,
pub agent_time_s: f64,
pub avg_session_s: f64,
pub success_rate: f64,
pub completed: i64,
pub failed: i64,
pub abandoned: i64,
pub running: i64,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CostByModelDTO {
pub model: String,
pub session_count: i64,
pub cost_usd: f64,
pub tokens_in: i64,
pub tokens_out: i64,
}
impl From<cognee_database::CostByModelRow> for CostByModelDTO {
fn from(row: cognee_database::CostByModelRow) -> Self {
Self {
model: row.model,
session_count: row.session_count,
cost_usd: row.cost_usd,
tokens_in: row.tokens_in,
tokens_out: row.tokens_out,
}
}
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SessionDetailDTO {
#[serde(flatten)]
pub record: SessionRowDTO,
pub label: Option<String>,
pub msg_count: usize,
pub tool_calls: usize,
pub qas: Vec<serde_json::Value>,
pub traces: Vec<serde_json::Value>,
}
impl From<cognee_database::SessionRowWithStatus> for SessionRowDTO {
fn from(row: cognee_database::SessionRowWithStatus) -> Self {
let cognee_database::SessionRowWithStatus {
record,
effective_status,
} = row;
Self {
session_id: record.session_id,
user_id: record.user_id,
dataset_id: record.dataset_id,
status: record.status,
started_at: record.started_at,
last_activity_at: record.last_activity_at,
ended_at: record.ended_at,
tokens_in: record.tokens_in,
tokens_out: record.tokens_out,
cost_usd: record.cost_usd,
error_count: record.error_count,
last_model: record.last_model,
effective_status,
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code — panics are acceptable failures"
)]
mod tests {
use super::*;
#[test]
fn range_window_default_is_30d() {
assert_eq!(RangeWindow::default(), RangeWindow::D30);
}
#[test]
fn order_by_default_is_last_activity_at() {
assert_eq!(OrderBy::default(), OrderBy::LastActivityAt);
assert_eq!(OrderBy::LastActivityAt.as_column(), "last_activity_at");
assert_eq!(OrderBy::CostUsd.as_column(), "cost_usd");
}
#[test]
fn list_sessions_query_defaults() {
let q: ListSessionsQuery = serde_urlencoded::from_str("").expect("empty query");
assert_eq!(q.range, RangeWindow::D30);
assert_eq!(q.limit, 50);
assert_eq!(q.offset, 0);
assert_eq!(q.order_by, OrderBy::LastActivityAt);
assert!(q.descending);
assert!(q.status.is_none());
}
#[test]
fn list_sessions_query_parses_all_fields() {
let q: ListSessionsQuery = serde_urlencoded::from_str(
"range=24h&status=running&limit=200&offset=10&order_by=cost_usd&descending=false",
)
.expect("parse query");
assert_eq!(q.range, RangeWindow::H24);
assert_eq!(q.status.as_deref(), Some("running"));
assert_eq!(q.limit, 200);
assert_eq!(q.offset, 10);
assert_eq!(q.order_by, OrderBy::CostUsd);
assert!(!q.descending);
}
#[test]
fn range_window_rejects_90d() {
let res: Result<ListSessionsQuery, _> = serde_urlencoded::from_str("range=90d");
assert!(res.is_err(), "90d must be rejected");
}
#[test]
fn order_by_rejects_unknown_variant() {
let res: Result<ListSessionsQuery, _> = serde_urlencoded::from_str("order_by=banana");
assert!(res.is_err(), "unknown order_by must be rejected");
}
#[test]
fn stats_query_defaults_to_30d() {
let q: StatsQuery = serde_urlencoded::from_str("").expect("empty query");
assert_eq!(q.range, RangeWindow::D30);
}
#[test]
fn range_window_as_wire_str_round_trips() {
assert_eq!(RangeWindow::H24.as_wire_str(), "24h");
assert_eq!(RangeWindow::D7.as_wire_str(), "7d");
assert_eq!(RangeWindow::D30.as_wire_str(), "30d");
assert_eq!(RangeWindow::All.as_wire_str(), "all");
}
#[test]
fn session_stats_dto_emits_snake_case_keys() {
let dto = SessionStatsDTO {
range: "30d".into(),
sessions: 3,
total_spend_usd: 1.5,
avg_spend_per_session_usd: 0.5,
tokens_in: 10,
tokens_out: 20,
tokens_total: 30,
agent_time_s: 12.5,
avg_session_s: 4.0,
success_rate: 0.75,
completed: 2,
failed: 1,
abandoned: 0,
running: 0,
};
let s = serde_json::to_string(&dto).expect("serialize");
assert!(s.contains("\"range\""), "expected range key: {s}");
assert!(
s.contains("\"total_spend_usd\""),
"expected snake_case total_spend_usd: {s}"
);
assert!(
s.contains("\"avg_spend_per_session_usd\""),
"expected snake_case avg_spend_per_session_usd: {s}"
);
assert!(
s.contains("\"success_rate\""),
"expected snake_case success_rate: {s}"
);
assert!(
s.contains("\"agent_time_s\""),
"expected snake_case agent_time_s: {s}"
);
assert!(
s.contains("\"avg_session_s\""),
"expected snake_case avg_session_s: {s}"
);
assert!(
s.contains("\"tokens_total\""),
"expected snake_case tokens_total: {s}"
);
assert!(!s.contains("totalSpendUsd"), "must not emit camelCase: {s}");
assert!(!s.contains("successRate"), "must not emit camelCase: {s}");
}
#[test]
fn cost_by_model_query_defaults_to_30d() {
let q: CostByModelQuery = serde_urlencoded::from_str("").expect("empty query");
assert_eq!(q.range, RangeWindow::D30);
}
#[test]
fn cost_by_model_dto_emits_snake_case_keys() {
let dto = CostByModelDTO {
model: "gpt-4o-mini".into(),
session_count: 3,
cost_usd: 1.25,
tokens_in: 100,
tokens_out: 200,
};
let s = serde_json::to_string(&dto).expect("serialize");
assert!(s.contains("\"model\""), "expected model key: {s}");
assert!(
s.contains("\"session_count\""),
"expected snake_case session_count: {s}"
);
assert!(
s.contains("\"cost_usd\""),
"expected snake_case cost_usd: {s}"
);
assert!(
s.contains("\"tokens_in\""),
"expected snake_case tokens_in: {s}"
);
assert!(
s.contains("\"tokens_out\""),
"expected snake_case tokens_out: {s}"
);
assert!(!s.contains("sessionCount"), "must not emit camelCase: {s}");
assert!(!s.contains("costUsd"), "must not emit camelCase: {s}");
assert!(!s.contains("tokensIn"), "must not emit camelCase: {s}");
assert!(!s.contains("tokensOut"), "must not emit camelCase: {s}");
}
#[test]
fn session_detail_dto_emits_snake_case_keys_and_flattens_record() {
use chrono::TimeZone;
let row = SessionRowDTO {
session_id: "s".into(),
user_id: "u".into(),
dataset_id: None,
status: "running".into(),
started_at: chrono::Utc
.with_ymd_and_hms(2026, 4, 29, 0, 0, 0)
.single()
.expect("valid"),
last_activity_at: chrono::Utc
.with_ymd_and_hms(2026, 4, 29, 0, 0, 1)
.single()
.expect("valid"),
ended_at: None,
tokens_in: 1,
tokens_out: 2,
cost_usd: 0.5,
error_count: 0,
last_model: Some("gpt-4o".into()),
effective_status: "running".into(),
};
let dto = SessionDetailDTO {
record: row,
label: Some("hello".into()),
msg_count: 3,
tool_calls: 4,
qas: vec![serde_json::json!({"question": "q?", "answer": "a"})],
traces: vec![serde_json::json!({"origin_function": "tool"})],
};
let s = serde_json::to_string(&dto).expect("serialize");
assert!(
s.contains("\"session_id\""),
"expected flattened session_id: {s}"
);
assert!(
s.contains("\"effective_status\""),
"expected flattened effective_status: {s}"
);
assert!(
!s.contains("\"record\""),
"must not wrap row in `record`: {s}"
);
assert!(s.contains("\"label\""), "expected label key: {s}");
assert!(
s.contains("\"msg_count\""),
"expected snake_case msg_count: {s}"
);
assert!(
s.contains("\"tool_calls\""),
"expected snake_case tool_calls: {s}"
);
assert!(s.contains("\"qas\""), "expected qas key: {s}");
assert!(s.contains("\"traces\""), "expected traces key: {s}");
assert!(
s.contains("+00:00"),
"expected Decision-6 +00:00 timestamp: {s}"
);
assert!(!s.contains("msgCount"), "must not emit camelCase: {s}");
assert!(!s.contains("toolCalls"), "must not emit camelCase: {s}");
}
#[test]
fn session_row_dto_emits_snake_case_keys() {
use chrono::TimeZone;
let dto = SessionRowDTO {
session_id: "s".into(),
user_id: "u".into(),
dataset_id: None,
status: "running".into(),
started_at: chrono::Utc
.with_ymd_and_hms(2026, 4, 29, 0, 0, 0)
.single()
.expect("valid"),
last_activity_at: chrono::Utc
.with_ymd_and_hms(2026, 4, 29, 0, 0, 1)
.single()
.expect("valid"),
ended_at: None,
tokens_in: 1,
tokens_out: 2,
cost_usd: 0.5,
error_count: 0,
last_model: Some("gpt-4o".into()),
effective_status: "running".into(),
};
let s = serde_json::to_string(&dto).expect("serialize");
assert!(
s.contains("\"session_id\""),
"expected snake_case session_id: {s}"
);
assert!(
s.contains("\"last_activity_at\""),
"expected snake_case last_activity_at: {s}"
);
assert!(
s.contains("\"effective_status\""),
"expected snake_case effective_status: {s}"
);
assert!(
s.contains("+00:00"),
"expected Decision-6 +00:00 timestamp: {s}"
);
}
}