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, TaskId, TraceId};
13use systemprompt_models::text::truncate_with_ellipsis;
14use systemprompt_runtime::{AuditPage, TraceQueryService};
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 error_message: Option<String>,
95    pub provider: Option<String>,
96    pub model: Option<String>,
97    pub requested_model: Option<String>,
98    pub input_tokens: i32,
99    pub output_tokens: i32,
100    pub cache_read_tokens: i32,
101    pub cache_creation_tokens: i32,
102    #[serde(default)]
103    pub reasoning_tokens: i32,
104    pub cost_dollars: f64,
105    pub latency_ms: i64,
106    pub task_id: Option<TaskId>,
107    pub trace_id: Option<TraceId>,
108    pub message_count: i64,
109    pub tool_call_count: i64,
110    pub offset: i64,
111    pub has_more: bool,
112    pub messages: Vec<MessageRow>,
113    pub tool_calls: Vec<AuditToolCall>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117pub struct AuditToolCall {
118    pub tool_name: String,
119    pub tool_input: String,
120    pub sequence: i32,
121}
122
123crate::define_pool_command!(AuditArgs => (), with_config);
124
125async fn execute_with_pool_inner(
126    args: AuditArgs,
127    pool: &Arc<sqlx::PgPool>,
128    config: &CliConfig,
129) -> Result<()> {
130    let service = TraceQueryService::new(Arc::clone(pool));
131
132    let row = service.find_ai_request_for_audit(&args.id).await?;
133
134    let Some(row) = row else {
135        render_result(&not_found_output(&args.id), config);
136        return Ok(());
137    };
138
139    let request_id = row.id;
140    let page = args.page();
141    let (message_count, tool_call_count) = tokio::try_join!(
142        service.count_audit_messages(&request_id),
143        service.count_audit_tool_calls(&request_id),
144    )?;
145    let messages = if args.messages {
146        service.list_audit_messages(&request_id, page).await?
147    } else {
148        Vec::new()
149    };
150    let tool_calls = if args.tools {
151        service.list_audit_tool_calls(&request_id, page).await?
152    } else {
153        Vec::new()
154    };
155    let page_end = page.offset.saturating_add(page.limit);
156    let has_more = page.limit > 0
157        && ((args.messages && page_end < message_count)
158            || (args.tools && page_end < tool_call_count));
159
160    let output = AuditOutput {
161        request_id,
162        status: row.status,
163        error_message: row.error_message,
164        provider: row.provider,
165        model: row.model,
166        requested_model: row.requested_model,
167        input_tokens: row.input_tokens.unwrap_or(0),
168        output_tokens: row.output_tokens.unwrap_or(0),
169        cache_read_tokens: row.cache_read_tokens.unwrap_or(0),
170        reasoning_tokens: row.reasoning_tokens.unwrap_or(0),
171        cache_creation_tokens: row.cache_creation_tokens.unwrap_or(0),
172        cost_dollars: row.cost_microdollars as f64 / 1_000_000.0,
173        latency_ms: i64::from(row.latency_ms.unwrap_or(0)),
174        task_id: row.task_id,
175        trace_id: row.trace_id.map(TraceId::new),
176        message_count,
177        tool_call_count,
178        offset: page.offset,
179        has_more,
180        messages: messages
181            .into_iter()
182            .map(|m| MessageRow {
183                sequence: m.sequence_number,
184                role: m.role,
185                content: args.bounded(m.content),
186            })
187            .collect(),
188        tool_calls: tool_calls
189            .into_iter()
190            .map(|t| AuditToolCall {
191                tool_name: t.tool_name,
192                tool_input: args.bounded(t.tool_input),
193                sequence: t.sequence_number,
194            })
195            .collect(),
196    };
197
198    render_result(&build_audit(&output), config);
199
200    Ok(())
201}
202
203#[must_use]
204pub fn build_audit(output: &AuditOutput) -> CommandOutput {
205    let title = if output.status == "completed" {
206        "AI Request Audit".to_owned()
207    } else {
208        format!("AI Request Audit — {}", output.status.to_uppercase())
209    };
210    CommandOutput::card_value(title, output)
211}
212
213#[must_use]
214pub fn not_found_output(id: &str) -> CommandOutput {
215    use systemprompt_models::artifacts::NoticeLine;
216    CommandOutput::message(vec![
217        NoticeLine::new("warning", format!("No AI request found for: {id}")),
218        NoticeLine::new(
219            "info",
220            "Tip: Use 'systemprompt infra logs request list' to see recent requests",
221        ),
222        NoticeLine::new(
223            "info",
224            "Use 'systemprompt infra logs trace list' to see recent traces",
225        ),
226    ])
227}