use anyhow::Result;
use clap::Args;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use systemprompt_identifiers::TraceId;
use systemprompt_logging::{CliService, TraceQueryService};
use super::duration::parse_since;
use super::shared::display_log_row;
use super::{LogEntryRow, LogFilters};
use crate::CliConfig;
use crate::shared::CommandResult;
#[derive(Debug, Args)]
pub struct SearchArgs {
#[arg(help = "Search pattern (matches message content and tool names)")]
pub pattern: String,
#[arg(long, help = "Filter by log level (error, warn, info, debug, trace)")]
pub level: Option<String>,
#[arg(long, help = "Filter by module name (partial match)")]
pub module: Option<String>,
#[arg(
long,
help = "Only search logs since this duration (e.g., '1h', '24h', '7d') or datetime"
)]
pub since: Option<String>,
#[arg(long, short = 'n', default_value = "50", help = "Maximum results")]
pub limit: i64,
#[arg(long, default_value = "true", help = "Include MCP tool executions")]
pub include_tools: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ToolSearchResult {
pub timestamp: String,
pub trace_id: TraceId,
pub tool_name: String,
pub server: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CombinedSearchOutput {
pub logs: Vec<LogEntryRow>,
pub log_count: u64,
pub tools: Vec<ToolSearchResult>,
pub tool_count: u64,
pub filters: LogFilters,
}
crate::define_pool_command!(SearchArgs => CommandResult<CombinedSearchOutput>, with_config);
async fn execute_with_pool_inner(
args: SearchArgs,
pool: &Arc<sqlx::PgPool>,
config: &CliConfig,
) -> Result<CommandResult<CombinedSearchOutput>> {
let since_timestamp = parse_since(args.since.as_ref())?;
let level_filter = args.level.as_deref().map(str::to_uppercase);
let pattern = format!("%{}%", args.pattern);
let service = TraceQueryService::new(Arc::clone(pool));
let rows = service
.search_logs(
&pattern,
since_timestamp,
level_filter.as_deref(),
args.limit,
)
.await?;
let tool_rows = if args.include_tools {
service
.search_tool_executions(&pattern, since_timestamp, args.limit)
.await?
} else {
vec![]
};
let filtered_rows: Vec<_> = match &args.module {
Some(module) => rows
.into_iter()
.filter(|r| r.module.contains(module))
.collect(),
None => rows,
};
let logs: Vec<LogEntryRow> = filtered_rows
.into_iter()
.map(|r| LogEntryRow {
id: r.id,
trace_id: r.trace_id,
timestamp: r.timestamp.format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
level: r.level.to_uppercase(),
module: r.module,
message: r.message,
metadata: r.metadata.as_ref().and_then(|m| {
serde_json::from_str(m)
.map_err(|e| {
tracing::warn!(error = %e, "Failed to parse log metadata");
e
})
.ok()
}),
})
.collect();
let tools: Vec<ToolSearchResult> = tool_rows
.into_iter()
.map(|r| ToolSearchResult {
timestamp: r.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(),
trace_id: r.trace_id,
tool_name: r.tool_name,
server: r.server_name.unwrap_or_else(|| "unknown".to_string()),
status: r.status,
duration_ms: r.execution_time_ms.map(i64::from),
})
.collect();
let filters = LogFilters {
level: args.level.clone(),
module: args.module.clone(),
since: args.since.clone(),
pattern: Some(args.pattern.clone()),
tail: args.limit,
};
let output = CombinedSearchOutput {
log_count: logs.len() as u64,
logs,
tool_count: tools.len() as u64,
tools,
filters,
};
let result = CommandResult::table(output).with_title("Search Results");
if config.is_json_output() {
return Ok(result);
}
render_combined_results(
&result.data.logs,
&result.data.tools,
&args.pattern,
&result.data.filters,
);
Ok(result.with_skip_render())
}
fn render_combined_results(
logs: &[LogEntryRow],
tools: &[ToolSearchResult],
pattern: &str,
filters: &LogFilters,
) {
CliService::section(&format!("Search Results: \"{}\"", pattern));
if filters.level.is_some() || filters.module.is_some() || filters.since.is_some() {
if let Some(ref level) = filters.level {
CliService::key_value("Level", level);
}
if let Some(ref module) = filters.module {
CliService::key_value("Module", module);
}
if let Some(ref since) = filters.since {
CliService::key_value("Since", since);
}
}
if !tools.is_empty() {
CliService::subsection(&format!("MCP Tool Executions ({})", tools.len()));
for tool in tools {
let duration = tool.duration_ms.map(|d| format!(" ({}ms)", d));
let line = format!(
"{} {}/{} [{}]{} trace:{}",
tool.timestamp,
tool.server,
tool.tool_name,
tool.status,
duration.as_deref().unwrap_or(""),
tool.trace_id
);
match tool.status.as_str() {
"error" | "failed" => CliService::error(&line),
_ => CliService::info(&line),
}
}
}
if !logs.is_empty() {
CliService::subsection(&format!("Log Entries ({})", logs.len()));
for log in logs {
display_log_row(log);
}
}
if logs.is_empty() && tools.is_empty() {
CliService::warning("No matching results found");
return;
}
CliService::info(&format!(
"Found {} log entries and {} tool executions",
logs.len(),
tools.len()
));
}