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_logging::TraceQueryService;
14
15use super::types::MessageRow;
16use crate::CliConfig;
17use crate::shared::{CommandOutput, render_result};
18
19#[derive(Debug, Args)]
20pub struct AuditArgs {
21    #[arg(help = "AI request ID, task ID, or trace ID")]
22    pub id: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26pub struct AuditOutput {
27    pub request_id: AiRequestId,
28    pub status: String,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub error_message: Option<String>,
31    pub provider: Option<String>,
32    pub model: Option<String>,
33    pub requested_model: Option<String>,
34    pub input_tokens: i32,
35    pub output_tokens: i32,
36    pub cache_read_tokens: i32,
37    pub cache_creation_tokens: i32,
38    // Why: defaulted so an audit card deserialised from an older payload --
39    // fixtures and stored output predating the column -- still loads.
40    #[serde(default)]
41    pub reasoning_tokens: i32,
42    pub cost_dollars: f64,
43    pub latency_ms: i64,
44    pub task_id: Option<TaskId>,
45    pub trace_id: Option<TraceId>,
46    pub messages: Vec<MessageRow>,
47    pub tool_calls: Vec<AuditToolCall>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
51pub struct AuditToolCall {
52    pub tool_name: String,
53    pub tool_input: String,
54    pub sequence: i32,
55}
56
57crate::define_pool_command!(AuditArgs => (), with_config);
58
59async fn execute_with_pool_inner(
60    args: AuditArgs,
61    pool: &Arc<sqlx::PgPool>,
62    config: &CliConfig,
63) -> Result<()> {
64    let service = TraceQueryService::new(Arc::clone(pool));
65
66    let row = service.find_ai_request_for_audit(&args.id).await?;
67
68    let Some(row) = row else {
69        render_result(&not_found_output(&args.id), config);
70        return Ok(());
71    };
72
73    let request_id = row.id;
74    let (messages, tool_calls) = tokio::try_join!(
75        service.list_audit_messages(&request_id),
76        service.list_audit_tool_calls(&request_id),
77    )?;
78
79    let output = AuditOutput {
80        request_id,
81        status: row.status,
82        error_message: row.error_message,
83        provider: row.provider,
84        model: row.model,
85        requested_model: row.requested_model,
86        input_tokens: row.input_tokens.unwrap_or(0),
87        output_tokens: row.output_tokens.unwrap_or(0),
88        cache_read_tokens: row.cache_read_tokens.unwrap_or(0),
89        reasoning_tokens: row.reasoning_tokens.unwrap_or(0),
90        cache_creation_tokens: row.cache_creation_tokens.unwrap_or(0),
91        cost_dollars: row.cost_microdollars as f64 / 1_000_000.0,
92        latency_ms: i64::from(row.latency_ms.unwrap_or(0)),
93        task_id: row.task_id,
94        trace_id: row.trace_id.map(TraceId::new),
95        messages: messages
96            .into_iter()
97            .map(|m| MessageRow {
98                sequence: m.sequence_number,
99                role: m.role,
100                content: m.content,
101            })
102            .collect(),
103        tool_calls: tool_calls
104            .into_iter()
105            .map(|t| AuditToolCall {
106                tool_name: t.tool_name,
107                tool_input: t.tool_input,
108                sequence: t.sequence_number,
109            })
110            .collect(),
111    };
112
113    render_result(&build_audit(&output), config);
114
115    Ok(())
116}
117
118#[must_use]
119pub fn build_audit(output: &AuditOutput) -> CommandOutput {
120    // Why: audit is the richest single view; a failed request must announce
121    // itself in the title, not hide behind zero token counts.
122    let title = if output.status == "completed" {
123        "AI Request Audit".to_owned()
124    } else {
125        format!("AI Request Audit — {}", output.status.to_uppercase())
126    };
127    CommandOutput::card_value(title, output)
128}
129
130#[must_use]
131pub fn not_found_output(id: &str) -> CommandOutput {
132    use systemprompt_models::artifacts::NoticeLine;
133    CommandOutput::message(vec![
134        NoticeLine::new("warning", format!("No AI request found for: {id}")),
135        NoticeLine::new(
136            "info",
137            "Tip: Use 'systemprompt infra logs request list' to see recent requests",
138        ),
139        NoticeLine::new(
140            "info",
141            "Use 'systemprompt infra logs trace list' to see recent traces",
142        ),
143    ])
144}