Skip to content
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,19 @@ Run one command to instantly review all of your CLI coding agent usage. Upload

Run `splitrail web` and open `http://127.0.0.1:8765`. Use `splitrail web --port 9000` to choose another port. The server listens only on `127.0.0.1` and accepts that IP in the Host header. It reads local usage through the existing analyzers without uploading it. Static assets are embedded in the binary; no frontend build or Node installation is needed to run it.

The overview shows estimated cost, total tokens, session count, and cache share, with comparisons against the previous period. Filter by today, 7, 30, or 90 days, or all history, and select tools, models, or projects. Selections within one field combine; selections across fields narrow the result. Linked worktrees, repository subdirectories, and paths connected by an analyzer's project hash are grouped into one project. Hover a project to see its merged paths.
The overview shows estimated cost, total tokens, replies (model responses), session count, and cache share, with comparisons against the previous period. Filter by today, 7, 30, or 90 days, or all history, and select tools, models, or projects. Selections within one field combine; selections across fields narrow the result. Linked worktrees, repository subdirectories, and paths connected by an analyzer's project hash are grouped into one project. Hover a project to see its merged paths.

Switch between cost and tokens, and group the stacked trend by model, tool, or project. The chart supports hourly, daily, weekly, and monthly intervals; click a daily bar to inspect that day by hour. Focus the chart and use the arrow keys to read values, or Enter to drill into a day. Model, tool, and project rankings also filter on click. The five entities with the highest cost in the selected period keep their colors while filters change; the rest are grouped as Other.
Switch between cost, tokens, and replies, and group the stacked trend by model, tool, or project. The chart supports hourly, daily, weekly, and monthly intervals; click a daily bar to inspect that day by hour. Focus the chart and use the arrow keys to read values, or Enter to drill into a day. Model, tool, and project rankings also filter on click. The five entities with the highest cost in the selected period keep their colors while filters change; the rest are grouped as Other.

Usage snapshots are saved in the system cache directory under `splitrail/web-usage-v1.json`. On restart, the page immediately shows the saved snapshot while a background scan refreshes it, then updates automatically. The first run without a valid cache waits for a scan. Click Refresh to scan again. Concurrent refreshes share one scan, cached pages remain available during scanning, and a failed refresh preserves the previous snapshot. Cache files with a different application version or timezone are ignored. Dates and hours use the machine's local time. Total tokens match the TUI: input + output + cached; reasoning tokens are not added again. Costs use public API prices and may differ from subscription spending.

Frontend aggregation tests run with `node --test src/web/data.test.mjs`.

The Sessions tab provides search, sorting by recency/cost/tokens/duration, and a paginated list. Select a session to see its whole-session totals, reply and tool-call counts, token mix, model usage, and hourly or daily timeline. Shared filters select sessions; the detail panel shows the entire selected session.
Model aliases configured in `~/.splitrail.toml` also determine dashboard names and grouping, including filters, charts, rankings, and CSV exports. Session details show the original names beneath mapped names. Unmapped models keep their original names. Restart `splitrail web` after changing aliases; saved snapshots retain the original names and use the current aliases on load.

The Breakdown tab groups the filtered usage by period, project, model, or tool. Export CSV downloads the same groups, columns, ordering, and total row with exact numeric values. Shares are fractions between 0 and 1; text labels are escaped for spreadsheet use. View and filter selections are kept in the URL hash for bookmarks.
The Sessions tab provides search, sorting by recency/cost/tokens/replies/active time, and a paginated list. A compact summary shows total active time, replies, cost and tokens per reply, cache share, and project count. Above the list, the session shape card buckets the filtered sessions by reply count, active time, or tool calls, with the median and p90 beside it; the work activity card shows recorded tool calls, file reads, file operations, and shell commands. Active time counts gaps under five minutes between messages, so long idle periods do not inflate session duration. Select a session to see its whole-session totals, reply and tool-call counts, file reads and edits, token mix, per-model replies, tokens, cache share and cost, and its hourly or daily timeline. Shared filters select sessions; the detail panel shows the entire selected session. File and command counts are tool-operation counts, not unique file counts, and are available only where the source analyzer records them.

The Breakdown tab groups the filtered usage by period, project, model, or tool, and includes tool calls, file reads, file operations, and shell commands. Export CSV downloads the same groups, columns, ordering, and total row with exact numeric values. Shares are fractions between 0 and 1; text labels are escaped for spreadsheet use. View and filter selections are kept in the URL hash for bookmarks.

English is the default language; choose Simplified or Traditional Chinese in the header. The preference is saved locally. The dashboard follows the system's light/dark appearance and adapts to phone widths, with horizontally scrollable charts and tables. Overview also includes a weekday/hour activity heatmap and the largest sessions.

Expand Down
140 changes: 109 additions & 31 deletions src/analyzers/codex_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,25 +91,38 @@ pub(crate) fn data_dirs_with_roots(
.unwrap_or_default()
}

/// Session files are plain JSONL, or `*.jsonl.zst` once Codex compresses them.
fn session_file_name(path: &Path) -> Option<&str> {
let name = path.file_name()?.to_str()?;
Some(name.strip_suffix(".zst").unwrap_or(name))
}

pub(crate) fn is_codex_session_file(path: &Path) -> bool {
session_file_name(path).is_some_and(|name| name.ends_with(".jsonl"))
}

pub(crate) fn canonical_session_path(file_path: &Path) -> PathBuf {
// Drop a compression suffix so a session keeps the same identity (and hash)
// after Codex compresses it in place.
let Some(file_name) = session_file_name(file_path) else {
return file_path.to_path_buf();
};
let uncompressed_path = file_path.with_file_name(file_name);
let Some(archive_dir) = file_path
.ancestors()
.find(|path| path.file_name() == Some(OsStr::new("archived_sessions")))
else {
return file_path.to_path_buf();
return uncompressed_path;
};
let Some(codex_dir) = archive_dir.parent() else {
return file_path.to_path_buf();
};
let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else {
return file_path.to_path_buf();
return uncompressed_path;
};
let Some(date) = file_name
.strip_prefix("rollout-")
.and_then(|rest| rest.get(..10))
.and_then(|date| chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok())
else {
return file_path.to_path_buf();
return uncompressed_path;
};

codex_dir
Expand All @@ -129,13 +142,7 @@ pub(crate) fn discover_data_sources_in(
.filter(|dir| dir.is_dir())
.flat_map(|dir| WalkDir::new(dir).into_iter())
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry.file_type().is_file()
&& entry
.path()
.extension()
.is_some_and(|extension| extension == "jsonl")
})
.filter(|entry| entry.file_type().is_file() && is_codex_session_file(entry.path()))
.filter(|entry| seen_sessions.insert(canonical_session_path(entry.path())))
.map(|entry| DataSource {
path: entry.into_path(),
Expand All @@ -145,9 +152,7 @@ pub(crate) fn discover_data_sources_in(

pub(crate) fn is_valid_data_path_in(path: &Path, data_dirs: &[PathBuf]) -> bool {
path.is_file()
&& path
.extension()
.is_some_and(|extension| extension == "jsonl")
&& is_codex_session_file(path)
&& data_dirs.iter().any(|data_dir| path.starts_with(data_dir))
}

Expand All @@ -160,7 +165,13 @@ impl Analyzer for CodexCliAnalyzer {
fn get_data_glob_patterns(&self) -> Vec<String> {
Self::data_dirs()
.into_iter()
.map(|data_dir| format!("{}/**/*.jsonl", data_dir.to_string_lossy()))
.flat_map(|data_dir| {
let base = data_dir.to_string_lossy().into_owned();
[
format!("{base}/**/*.jsonl"),
format!("{base}/**/*.jsonl.zst"),
]
})
.collect()
}

Expand All @@ -174,9 +185,7 @@ impl Analyzer for CodexCliAnalyzer {
.filter(|d| d.is_dir())
.flat_map(|dir| WalkDir::new(dir).into_iter())
.filter_map(|e| e.ok())
.any(|e| {
e.file_type().is_file() && e.path().extension().is_some_and(|ext| ext == "jsonl")
})
.any(|e| e.file_type().is_file() && is_codex_session_file(e.path()))
}

fn parse_source(&self, source: &DataSource) -> Result<Vec<ConversationMessage>> {
Expand Down Expand Up @@ -314,6 +323,25 @@ fn is_noise_title_candidate(text: &str) -> bool {
|| trimmed.starts_with("# AGENTS.md instructions for")
}

fn record_tool_call(name: &str, stats: &mut Stats) {
stats.tool_calls = stats.tool_calls.saturating_add(1);
match name {
"exec_command" | "exec" | "shell" | "bash" | "local_shell" | "run_terminal_command" => {
stats.terminal_commands = stats.terminal_commands.saturating_add(1);
}
"apply_patch" | "edit" | "edit_file" => {
stats.files_edited = stats.files_edited.saturating_add(1);
}
"read" | "read_file" | "view_file" => {
stats.files_read = stats.files_read.saturating_add(1);
}
"write" | "write_file" | "create_file" => {
stats.files_added = stats.files_added.saturating_add(1);
}
_ => {}
}
}

/// Returns (messages, detected_session_model_name)
pub(crate) fn parse_codex_cli_jsonl_file(
file_path: &Path,
Expand All @@ -325,17 +353,34 @@ pub(crate) fn parse_codex_cli_jsonl_file(
.to_string_lossy()
.into_owned();

let mut file = File::open(file_path)?;

// Read entire file at once to avoid per-line allocations
// Read entire file at once to avoid per-line allocations. Codex compresses
// closed sessions in place to `*.jsonl.zst`, which is transparently decoded
// here so both forms parse identically. The local data is never rewritten.
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
if file_path
.extension()
.is_some_and(|extension| extension == "zst")
{
let mut decoder = zstd::stream::read::Decoder::new(File::open(file_path)?)?;
// A torn or truncated frame still decodes its complete blocks, so keep
// those messages instead of dropping the whole session.
if let Err(error) = decoder.read_to_end(&mut buffer) {
warn_once(format!(
"WARNING: Codex session {file_path_str} has a truncated compressed tail; parsing {} decoded bytes: {error}",
buffer.len()
));
}
} else {
File::open(file_path)?.read_to_end(&mut buffer)?;
}

let mut session_model: Option<SessionModel> = None;
let mut previous_total_usage: Option<CodexCliTokenUsage> = None;
let mut saw_token_usage = false;
let mut _turn_context: Option<CodexCliTurnContext> = None;
let mut current_tool_call_ids: HashSet<String> = HashSet::with_capacity(20);
let mut current_tool_stats = Stats::default();
let mut pending_tool_entries = Vec::new();
let mut session_name: Option<String> = None;
let mut fallback_session_name: Option<String> = None;
let mut project_path: Option<String> = None;
Expand Down Expand Up @@ -409,16 +454,41 @@ pub(crate) fn parse_codex_cli_jsonl_file(
"response_item" => {
if let simd_json::OwnedValue::Object(map) = &wrapper.payload
&& let Some(simd_json::OwnedValue::String(item_type)) = map.get("type")
&& item_type == "function_call"
&& matches!(item_type.as_str(), "function_call" | "custom_tool_call")
{
if let Some(simd_json::OwnedValue::String(call_id)) = map.get("call_id") {
current_tool_call_ids.insert(call_id.clone());
} else {
current_tool_call_ids.insert(format!(
let call_id = match map.get("call_id") {
Some(simd_json::OwnedValue::String(call_id)) => call_id.clone(),
_ => format!(
"{}_{}",
wrapper.timestamp.to_rfc3339(),
current_tool_call_ids.len()
));
),
};
if current_tool_call_ids.insert(call_id.clone())
&& let Some(simd_json::OwnedValue::String(name)) = map.get("name")
{
let mut stats = Stats::default();
record_tool_call(name, &mut stats);
record_tool_call(name, &mut current_tool_stats);
pending_tool_entries.push(ConversationMessage {
application: Application::CodexCli,
date: wrapper.timestamp,
project_hash: project_hash.clone(),
project_path: project_path.clone(),
conversation_hash: hash_text(&session_path_str),
local_hash: None,
global_hash: hash_text(&format!("{session_path_str}_tool_{call_id}")),
model: Some(session_model.as_ref().map_or_else(
|| get_fallback_model().to_string(),
|model| model.name.clone(),
)),
stats,
role: MessageRole::Assistant,
uuid: None,
session_name: session_name
.clone()
.or_else(|| fallback_session_name.clone()),
});
}
continue;
}
Expand Down Expand Up @@ -598,8 +668,14 @@ pub(crate) fn parse_codex_cli_jsonl_file(
&model_state.name,
wrapper.timestamp,
);
stats.tool_calls = current_tool_call_ids.len() as u32;
stats.tool_calls = current_tool_stats.tool_calls;
stats.files_read = current_tool_stats.files_read;
stats.files_edited = current_tool_stats.files_edited;
stats.files_added = current_tool_stats.files_added;
stats.terminal_commands = current_tool_stats.terminal_commands;
current_tool_call_ids.clear();
current_tool_stats = Stats::default();
pending_tool_entries.clear();

entries.push(ConversationMessage {
application: Application::CodexCli,
Expand Down Expand Up @@ -634,6 +710,8 @@ pub(crate) fn parse_codex_cli_jsonl_file(
}
}

entries.extend(pending_tool_entries);

// Return both messages and the detected session model name
let detected_model = session_model.map(|m| m.name);
Ok((entries, detected_model))
Expand Down
Loading
Loading