Skip to main content

systemprompt_cli/commands/infrastructure/logs/request/
mod.rs

1//! `infra logs request` subcommands for inspecting AI provider requests.
2//!
3//! Exposes [`RequestCommands`] (list, show, stats) and the row types
4//! ([`RequestListRow`], [`RequestShowOutput`]) returned to the renderer.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9mod list;
10mod show;
11mod stats;
12
13use anyhow::Result;
14use clap::Subcommand;
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18use super::types::{MessageRow, ToolCallRow};
19use crate::context::CommandContext;
20use crate::shared::{CommandOutput, render_result};
21use serde_json::Value as JsonValue;
22use systemprompt_identifiers::UserId;
23use systemprompt_models::artifacts::{Column, ColumnType, NoticeLine, TableArtifact};
24
25pub use stats::{RequestStatsOutput, build_request_stats};
26
27const REQUEST_ID_DISPLAY_WIDTH: usize = 12;
28
29fn request_list_columns() -> Vec<Column> {
30    vec![
31        Column::new("request_id", ColumnType::String).with_width(REQUEST_ID_DISPLAY_WIDTH),
32        Column::new("timestamp", ColumnType::String),
33        Column::new("user_id", ColumnType::String),
34        Column::new("actor", ColumnType::String),
35        Column::new("client", ColumnType::String),
36        Column::new("attestation", ColumnType::String),
37        Column::new("provider", ColumnType::String),
38        Column::new("model", ColumnType::String),
39        Column::new("tokens", ColumnType::String),
40        Column::new("cost", ColumnType::String),
41        Column::new("latency_ms", ColumnType::Number),
42        Column::new("status", ColumnType::String),
43    ]
44}
45
46#[must_use]
47pub fn build_request_list(rows: &[RequestListRow]) -> CommandOutput {
48    if rows.is_empty() {
49        return CommandOutput::message(vec![NoticeLine::new("info", "No AI requests found")]);
50    }
51    let items: Vec<JsonValue> = rows
52        .iter()
53        .map(|row| serde_json::to_value(row).unwrap_or(JsonValue::Null))
54        .collect();
55    CommandOutput::table_artifact(TableArtifact::new(request_list_columns()).with_rows(items))
56        .with_title("AI Requests")
57}
58
59#[must_use]
60pub fn build_request_show(detail: &RequestShowOutput) -> CommandOutput {
61    CommandOutput::card_value("AI Request Details", detail)
62}
63
64#[must_use]
65pub fn request_show_not_found(request_id: &str) -> CommandOutput {
66    CommandOutput::message(vec![
67        NoticeLine::new("warning", format!("AI request not found: {request_id}")),
68        NoticeLine::new(
69            "info",
70            "Tip: Use 'systemprompt infra logs request list' to see recent requests",
71        ),
72    ])
73}
74
75#[derive(Debug, Subcommand)]
76pub enum RequestCommands {
77    #[command(
78        about = "Operational list of recent AI requests. For dashboard metrics (time range, model filter, CSV export), use `analytics requests list`",
79        after_help = "EXAMPLES:\n  systemprompt infra logs request list\n  systemprompt infra \
80                      logs request list --model gpt-4 --since 1h"
81    )]
82    List(list::ListArgs),
83
84    #[command(
85        about = "Quick single-request view by request id (messages, linked MCP calls, status/error)",
86        after_help = "EXAMPLES:\n  systemprompt infra logs request show abc123\n  systemprompt \
87                      infra logs request show abc123 --messages --tools"
88    )]
89    Show(show::ShowArgs),
90
91    #[command(
92        about = "Operational request aggregate with by-provider / by-model breakdown. For range/model-filtered dashboards with export, use `analytics requests stats`",
93        after_help = "EXAMPLES:\n  systemprompt infra logs request stats\n  systemprompt infra \
94                      logs request stats --since 24h"
95    )]
96    Stats(stats::StatsArgs),
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
100pub struct RequestListRow {
101    pub request_id: String,
102    pub timestamp: String,
103    pub cursor: String,
104    pub user_id: UserId,
105    pub actor: String,
106    pub client: String,
107    pub attestation: String,
108    pub provider: String,
109    pub model: String,
110    pub tokens: String,
111    pub cost: String,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub latency_ms: Option<i64>,
114    pub status: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
118pub struct RequestShowOutput {
119    pub request_id: String,
120    pub user_id: UserId,
121    pub actor_kind: String,
122    pub actor_id: String,
123    pub client: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub client_evidence: Option<ClientEvidenceOutput>,
126    pub provider: Option<String>,
127    pub model: Option<String>,
128    pub input_tokens: i32,
129    pub output_tokens: i32,
130    pub cost_dollars: f64,
131    pub latency_ms: i64,
132    pub status: String,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub error_message: Option<String>,
135    pub messages: Vec<MessageRow>,
136    pub linked_mcp_calls: Vec<ToolCallRow>,
137}
138
139/// Only the evidence the wire presented is rendered; a field the request did
140/// not carry is omitted rather than shown empty.
141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
142pub struct ClientEvidenceOutput {
143    pub kind_source: String,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub attested_host: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub declared_client: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub native_marker: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub ua_product: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub ua_version: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub sdk_lang: Option<String>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub sdk_package_version: Option<String>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub sdk_runtime: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub sdk_runtime_version: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub sdk_os: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub sdk_arch: Option<String>,
166}
167
168impl From<systemprompt_runtime::AiRequestClientEvidence> for ClientEvidenceOutput {
169    fn from(evidence: systemprompt_runtime::AiRequestClientEvidence) -> Self {
170        Self {
171            kind_source: evidence.kind_source,
172            attested_host: evidence.attested_host,
173            declared_client: evidence.declared_client,
174            native_marker: evidence.native_marker,
175            ua_product: evidence.ua_product,
176            ua_version: evidence.ua_version,
177            sdk_lang: evidence.sdk_lang,
178            sdk_package_version: evidence.sdk_package_version,
179            sdk_runtime: evidence.sdk_runtime,
180            sdk_runtime_version: evidence.sdk_runtime_version,
181            sdk_os: evidence.sdk_os,
182            sdk_arch: evidence.sdk_arch,
183        }
184    }
185}
186
187pub async fn execute(command: RequestCommands, ctx: &CommandContext) -> Result<()> {
188    match command {
189        RequestCommands::List(args) => {
190            let result = list::execute(args, ctx).await?;
191            render_result(&result, &ctx.cli);
192            Ok(())
193        },
194        RequestCommands::Show(args) => {
195            let result = show::execute(args, ctx).await?;
196            render_result(&result, &ctx.cli);
197            Ok(())
198        },
199        RequestCommands::Stats(args) => stats::execute(args, ctx).await,
200    }
201}