feat: improve logs client (#10229)

This commit is contained in:
jif-oai
2026-01-30 18:23:18 +01:00
committed by GitHub
parent 887bec0dee
commit eff11f792b
3 changed files with 148 additions and 61 deletions

View File

@@ -3,8 +3,6 @@ use std::time::Duration;
use anyhow::Context;
use chrono::DateTime;
use chrono::SecondsFormat;
use chrono::Utc;
use clap::Parser;
use codex_state::LogQuery;
use codex_state::LogRow;
@@ -37,17 +35,21 @@ struct Args {
#[arg(long, value_name = "RFC3339|UNIX")]
to: Option<String>,
/// Substring match on module_path.
#[arg(long)]
module: Option<String>,
/// Substring match on module_path. Repeat to include multiple substrings.
#[arg(long = "module")]
module: Vec<String>,
/// Substring match on file path.
#[arg(long)]
file: Option<String>,
/// Substring match on file path. Repeat to include multiple substrings.
#[arg(long = "file")]
file: Vec<String>,
/// Match a specific thread id.
/// Match one or more thread ids. Repeat to include multiple threads.
#[arg(long = "thread-id")]
thread_id: Vec<String>,
/// Include logs that do not have a thread id.
#[arg(long)]
thread_id: Option<String>,
threadless: bool,
/// Number of matching rows to show before tailing.
#[arg(long, default_value_t = 200)]
@@ -63,9 +65,10 @@ struct LogFilter {
level_upper: Option<String>,
from_ts: Option<i64>,
to_ts: Option<i64>,
module_like: Option<String>,
file_like: Option<String>,
thread_id: Option<String>,
module_like: Vec<String>,
file_like: Vec<String>,
thread_ids: Vec<String>,
include_threadless: bool,
}
#[tokio::main]
@@ -126,14 +129,33 @@ fn build_filter(args: &Args) -> anyhow::Result<LogFilter> {
.context("failed to parse --to")?;
let level_upper = args.level.as_ref().map(|level| level.to_ascii_uppercase());
let module_like = args
.module
.iter()
.filter(|module| !module.is_empty())
.cloned()
.collect::<Vec<_>>();
let file_like = args
.file
.iter()
.filter(|file| !file.is_empty())
.cloned()
.collect::<Vec<_>>();
let thread_ids = args
.thread_id
.iter()
.filter(|thread_id| !thread_id.is_empty())
.cloned()
.collect::<Vec<_>>();
Ok(LogFilter {
level_upper,
from_ts,
to_ts,
module_like: args.module.clone(),
file_like: args.file.clone(),
thread_id: args.thread_id.clone(),
module_like,
file_like,
thread_ids,
include_threadless: args.threadless,
})
}
@@ -211,7 +233,8 @@ fn to_log_query(
to_ts: filter.to_ts,
module_like: filter.module_like.clone(),
file_like: filter.file_like.clone(),
thread_id: filter.thread_id.clone(),
thread_ids: filter.thread_ids.clone(),
include_threadless: filter.include_threadless,
after_id,
limit,
descending,
@@ -219,45 +242,82 @@ fn to_log_query(
}
fn format_row(row: &LogRow) -> String {
let timestamp = format_timestamp(row.ts, row.ts_nanos);
let timestamp = formatter::ts(row.ts, row.ts_nanos);
let level = row.level.as_str();
let target = row.target.as_str();
let message = row.message.as_deref().unwrap_or("");
let level_colored = color_level(level);
let level_colored = formatter::level(level);
let timestamp_colored = timestamp.dimmed().to_string();
let thread_id = row.thread_id.as_deref().unwrap_or("-");
let thread_id_colored = thread_id.blue().dimmed().to_string();
let target_colored = target.dimmed().to_string();
let message_colored = message.bold().to_string();
let message_colored = heuristic_formatting(message);
format!(
"{timestamp_colored} {level_colored} [{thread_id_colored}] {target_colored} - {message_colored}"
)
}
fn color_level(level: &str) -> String {
let padded = format!("{level:<5}");
if level.eq_ignore_ascii_case("error") {
return padded.red().bold().to_string();
fn heuristic_formatting(message: &str) -> String {
if matcher::apply_patch(message) {
formatter::apply_patch(message)
} else {
message.bold().to_string()
}
if level.eq_ignore_ascii_case("warn") {
return padded.yellow().bold().to_string();
}
if level.eq_ignore_ascii_case("info") {
return padded.green().bold().to_string();
}
if level.eq_ignore_ascii_case("debug") {
return padded.blue().bold().to_string();
}
if level.eq_ignore_ascii_case("trace") {
return padded.magenta().bold().to_string();
}
padded.bold().to_string()
}
fn format_timestamp(ts: i64, ts_nanos: i64) -> String {
let nanos = u32::try_from(ts_nanos).unwrap_or(0);
match DateTime::<Utc>::from_timestamp(ts, nanos) {
Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true),
None => format!("{ts}.{ts_nanos:09}Z"),
mod matcher {
pub(super) fn apply_patch(message: &str) -> bool {
message.starts_with("ToolCall: apply_patch")
}
}
mod formatter {
use chrono::DateTime;
use chrono::SecondsFormat;
use chrono::Utc;
use owo_colors::OwoColorize;
pub(super) fn apply_patch(message: &str) -> String {
message
.lines()
.map(|line| {
if line.starts_with('+') {
line.green().bold().to_string()
} else if line.starts_with('-') {
line.red().bold().to_string()
} else {
line.bold().to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn ts(ts: i64, ts_nanos: i64) -> String {
let nanos = u32::try_from(ts_nanos).unwrap_or(0);
match DateTime::<Utc>::from_timestamp(ts, nanos) {
Some(dt) => dt.to_rfc3339_opts(SecondsFormat::Millis, true),
None => format!("{ts}.{ts_nanos:09}Z"),
}
}
pub(super) fn level(level: &str) -> String {
let padded = format!("{level:<5}");
if level.eq_ignore_ascii_case("error") {
return padded.red().bold().to_string();
}
if level.eq_ignore_ascii_case("warn") {
return padded.yellow().bold().to_string();
}
if level.eq_ignore_ascii_case("info") {
return padded.green().bold().to_string();
}
if level.eq_ignore_ascii_case("debug") {
return padded.blue().bold().to_string();
}
if level.eq_ignore_ascii_case("trace") {
return padded.magenta().bold().to_string();
}
padded.bold().to_string()
}
}

View File

@@ -32,9 +32,10 @@ pub struct LogQuery {
pub level_upper: Option<String>,
pub from_ts: Option<i64>,
pub to_ts: Option<i64>,
pub module_like: Option<String>,
pub file_like: Option<String>,
pub thread_id: Option<String>,
pub module_like: Vec<String>,
pub file_like: Vec<String>,
pub thread_ids: Vec<String>,
pub include_threadless: bool,
pub after_id: Option<i64>,
pub limit: Option<usize>,
pub descending: bool,

View File

@@ -457,28 +457,54 @@ fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQu
if let Some(to_ts) = query.to_ts {
builder.push(" AND ts <= ").push_bind(to_ts);
}
if let Some(module_like) = query.module_like.as_ref() {
builder
.push(" AND module_path LIKE '%' || ")
.push_bind(module_like.as_str())
.push(" || '%'");
}
if let Some(file_like) = query.file_like.as_ref() {
builder
.push(" AND file LIKE '%' || ")
.push_bind(file_like.as_str())
.push(" || '%'");
}
if let Some(thread_id) = query.thread_id.as_ref() {
builder
.push(" AND thread_id = ")
.push_bind(thread_id.as_str());
push_like_filters(builder, "module_path", &query.module_like);
push_like_filters(builder, "file", &query.file_like);
let has_thread_filter = !query.thread_ids.is_empty() || query.include_threadless;
if has_thread_filter {
builder.push(" AND (");
let mut needs_or = false;
for thread_id in &query.thread_ids {
if needs_or {
builder.push(" OR ");
}
builder.push("thread_id = ").push_bind(thread_id.as_str());
needs_or = true;
}
if query.include_threadless {
if needs_or {
builder.push(" OR ");
}
builder.push("thread_id IS NULL");
}
builder.push(")");
}
if let Some(after_id) = query.after_id {
builder.push(" AND id > ").push_bind(after_id);
}
}
fn push_like_filters<'a>(
builder: &mut QueryBuilder<'a, Sqlite>,
column: &str,
filters: &'a [String],
) {
if filters.is_empty() {
return;
}
builder.push(" AND (");
for (idx, filter) in filters.iter().enumerate() {
if idx > 0 {
builder.push(" OR ");
}
builder
.push(column)
.push(" LIKE '%' || ")
.push_bind(filter.as_str())
.push(" || '%'");
}
builder.push(")");
}
async fn open_sqlite(path: &Path) -> anyhow::Result<SqlitePool> {
let options = SqliteConnectOptions::new()
.filename(path)