Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 100 additions & 40 deletions codex-rs/state/src/bin/logs_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand All @@ -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]
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -211,53 +233,91 @@ 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,
}
}

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();
}
if level.eq_ignore_ascii_case("warn") {
return padded.yellow().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("info") {
return padded.green().bold().to_string();
}

mod matcher {
pub(super) fn apply_patch(message: &str) -> bool {
message.starts_with("ToolCall: apply_patch")
}
if level.eq_ignore_ascii_case("debug") {
return padded.blue().bold().to_string();
}

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")
}
if level.eq_ignore_ascii_case("trace") {
return padded.magenta().bold().to_string();

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"),
}
}
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"),
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()
}
}
7 changes: 4 additions & 3 deletions codex-rs/state/src/model/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 42 additions & 16 deletions codex-rs/state/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading