Skip to main content

systemprompt_cli/commands/infrastructure/logs/
audit.rs

1//! `infra logs audit` command rendering the audit trail.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::sync::Arc;
7
8use anyhow::Result;
9use clap::Args;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use systemprompt_identifiers::{AiRequestId, McpToolName, TaskId, TraceId};
13use systemprompt_models::text::truncate_with_ellipsis;
14use systemprompt_runtime::{AuditPage, TraceQueryService, TraceRepository};
15
16use super::types::MessageRow;
17use crate::CliConfig;
18use crate::shared::{CommandOutput, render_result};
19
20#[derive(Debug, Args)]
21pub struct AuditArgs {
22    #[arg(help = "AI request ID, task ID, or trace ID")]
23    pub id: String,
24
25    #[arg(
26        long,
27        short = 'm',
28        help = "Include the conversation messages (default: counts only)"
29    )]
30    pub messages: bool,
31
32    #[arg(
33        long,
34        short = 't',
35        help = "Include the tool calls (default: counts only)"
36    )]
37    pub tools: bool,
38
39    #[arg(
40        long,
41        default_value = "0",
42        allow_negative_numbers = true,
43        value_parser = clap::value_parser!(i64).range(0..),
44        help = "Skip this many messages / tool calls before the page"
45    )]
46    pub offset: i64,
47
48    #[arg(
49        long,
50        short = 'n',
51        default_value = "20",
52        allow_negative_numbers = true,
53        value_parser = clap::value_parser!(i64).range(0..),
54        help = "Maximum messages / tool calls per page (0 = all)"
55    )]
56    pub limit: i64,
57
58    #[arg(
59        long,
60        value_name = "CHARS",
61        default_value = "0",
62        help = "Truncate each message body and tool input to this many characters (0 = full)"
63    )]
64    pub max_content: usize,
65}
66
67impl AuditArgs {
68    const fn page(&self) -> AuditPage {
69        AuditPage {
70            offset: self.offset,
71            limit: self.limit,
72        }
73    }
74
75    fn bounded(&self, text: String) -> String {
76        if self.max_content == 0 {
77            text
78        } else {
79            truncate_with_ellipsis(&text, self.max_content)
80        }
81    }
82}
83
84/// One audit page of a request.
85///
86/// `message_count` / `tool_call_count` are the request's totals whatever the
87/// page; `messages` / `tool_calls` are the opted-in slice from `offset`, and
88/// `has_more` says whether a later slice exists.
89#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct AuditOutput {
91    pub request_id: AiRequestId,
92    pub status: String,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub finish_reason: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub error_message: Option<String>,
97    pub provider: Option<String>,
98    pub model: Option<String>,
99    pub requested_model: Option<String>,
100    pub input_tokens: i32,
101    pub output_tokens: i32,
102    pub cache_read_tokens: i32,
103    pub cache_creation_tokens: i32,
104    #[serde(default)]
105    pub reasoning_tokens: i32,
106    pub cost_dollars: f64,
107    pub latency_ms: i64,
108    pub task_id: Option<TaskId>,
109    pub trace_id: Option<TraceId>,
110    pub message_count: i64,
111    pub tool_call_count: i64,
112    pub offset: i64,
113    pub has_more: bool,
114    pub messages: Vec<MessageRow>,
115    pub tool_calls: Vec<AuditToolCall>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
119pub struct AuditToolCall {
120    pub tool_name: McpToolName,
121    pub tool_input: String,
122    pub sequence: i32,
123}
124
125crate::define_pool_command!(AuditArgs => (), with_config);
126
127async fn execute_with_pool_inner(
128    args: AuditArgs,
129    pool: &Arc<sqlx::PgPool>,
130    config: &CliConfig,
131) -> Result<()> {
132    let service = TraceQueryService::new(TraceRepository::new(Arc::clone(pool)));
133
134    let row = service.find_ai_request_for_audit(&args.id).await?;
135
136    let Some(row) = row else {
137        render_result(&not_found_output(&args.id), config);
138        return Ok(());
139    };
140
141    let request_id = row.id;
142    let page = args.page();
143    let (message_count, tool_call_count) = tokio::try_join!(
144        service.count_audit_messages(&request_id),
145        service.count_audit_tool_calls(&request_id),
146    )?;
147    let messages = if args.messages {
148        service.list_audit_messages(&request_id, page).await?
149    } else {
150        Vec::new()
151    };
152    let tool_calls = if args.tools {
153        service.list_audit_tool_calls(&request_id, page).await?
154    } else {
155        Vec::new()
156    };
157    let page_end = page.offset.saturating_add(page.limit);
158    let has_more = page.limit > 0
159        && ((args.messages && page_end < message_count)
160            || (args.tools && page_end < tool_call_count));
161
162    let output = AuditOutput {
163        request_id,
164        status: row.status,
165        finish_reason: row.finish_reason,
166        error_message: row.error_message,
167        provider: row.provider,
168        model: row.model,
169        requested_model: row.requested_model,
170        input_tokens: row.input_tokens.unwrap_or(0),
171        output_tokens: row.output_tokens.unwrap_or(0),
172        cache_read_tokens: row.cache_read_tokens.unwrap_or(0),
173        reasoning_tokens: row.reasoning_tokens.unwrap_or(0),
174        cache_creation_tokens: row.cache_creation_tokens.unwrap_or(0),
175        cost_dollars: row.cost_microdollars as f64 / 1_000_000.0,
176        latency_ms: i64::from(row.latency_ms.unwrap_or(0)),
177        task_id: row.task_id,
178        trace_id: row.trace_id,
179        message_count,
180        tool_call_count,
181        offset: page.offset,
182        has_more,
183        messages: messages
184            .into_iter()
185            .map(|m| MessageRow {
186                sequence: m.sequence_number,
187                role: m.role,
188                content: args.bounded(m.content),
189            })
190            .collect(),
191        tool_calls: tool_calls
192            .into_iter()
193            .map(|t| AuditToolCall {
194                tool_name: t.tool_name,
195                tool_input: args.bounded(t.tool_input),
196                sequence: t.sequence_number,
197            })
198            .collect(),
199    };
200
201    render_result(&build_audit(&output), config);
202
203    Ok(())
204}
205
206#[must_use]
207pub fn build_audit(output: &AuditOutput) -> CommandOutput {
208    let title = if output.status == "completed" {
209        "AI Request Audit".to_owned()
210    } else {
211        format!("AI Request Audit — {}", output.status.to_uppercase())
212    };
213    CommandOutput::card_value(title, output)
214}
215
216#[must_use]
217pub fn not_found_output(id: &str) -> CommandOutput {
218    use systemprompt_models::artifacts::NoticeLine;
219    CommandOutput::message(vec![
220        NoticeLine::new("warning", format!("No AI request found for: {id}")),
221        NoticeLine::new(
222            "info",
223            "Tip: Use 'systemprompt infra logs request list' to see recent requests",
224        ),
225        NoticeLine::new(
226            "info",
227            "Use 'systemprompt infra logs trace list' to see recent traces",
228        ),
229    ])
230}