| name | log-ops |
| description | Log analysis and JSONL processing - structured extraction, cross-log correlation, timeline reconstruction, pattern search |
| license | MIT |
| allowed-tools | Read Edit Write Bash Glob Grep Agent |
| metadata | {"author":"claude-mods","related-skills":"data-processing, debug-ops, monitoring-ops, file-search, introspect"} |
Log Operations
Practical patterns for analyzing log files -- especially JSONL format used in agent conversation logs, benchmark outputs, and structured application logs.
Log Format Decision Tree
Unknown Log File
│
├─ Is it one JSON object per line?
│ ├─ Yes ──────────────────────── JSONL
│ │ ├─ Small file (<100MB)
│ │ │ └─ jq for extraction, jq -s for aggregation
│ │ ├─ Large file (100MB-1GB)
│ │ │ └─ rg prefilter then pipe to jq
│ │ └─ Huge file (>1GB)
│ │ └─ split + parallel jq, or jq --stream
│ │
│ └─ No
│ ├─ Is it one large JSON object/array?
│ │ └─ Yes ──────────────── Single JSON
│ │ └─ jq --stream for SAX-style, or jq directly if fits in memory
│ │
│ ├─ Does it have key=value pairs?
│ │ └─ Yes ──────────────── Structured (logfmt / key-value)
│ │ └─ rg for search, awk/sd for extraction, angle-grinder for aggregation
│ │
│ ├─ Does it follow syslog format? (timestamp hostname service[pid]: message)
│ │ └─ Yes ──────────────── Syslog
│ │ └─ rg for search, awk for column extraction, lnav for interactive
│ │
│ ├─ Is it space/tab delimited with consistent columns?
│ │ └─ Yes ──────────────── Column-based (access logs, CSV)
│ │ └─ awk for extraction, mlr for CSV, rg for pattern search
│ │
│ └─ Mixed or unstructured
│ └─ Plain text ─────────── Freeform
│ └─ rg for search, rg -A/-B for context, lnav for exploration
Prerequisites
Required (must be installed):
rg (ripgrep) - text search, prefiltering. Install: cargo install ripgrep / choco install ripgrep
jq - JSON/JSONL extraction and transformation. Install: brew install jq / choco install jq
Optional (enhanced capabilities, gracefully degraded without):
lnav - interactive log exploration with SQL queries. Install: brew install lnav / WSL: apt install lnav
agrind (angle-grinder) - pipeline aggregation syntax. Install: cargo install ag
mlr (Miller) - CSV/TSV log analysis. Install: brew install miller / choco install miller
GNU parallel - parallel processing of split files. Install: brew install parallel
All patterns in this skill work with just rg + jq. Optional tools add interactive exploration (lnav), pipeline aggregation (agrind), and tabular analysis (mlr).
Tool Selection Matrix
| Tool | Best For | Speed | Required? |
|---|
rg (ripgrep) | Raw pattern matching in any format | Fastest | Yes |
jq | JSONL structured extraction and transformation | Fast | Yes |
jq -s | JSONL aggregation (slurp all lines into array) | Medium (loads all into memory) | Yes (part of jq) |
lnav | Interactive exploration, SQL over logs | Interactive | Optional |
agrind (angle-grinder) | Pipeline aggregation and counting | Fast | Optional |
awk | Column-based log formats, field extraction | Fast | Pre-installed |
mlr (Miller) | CSV/TSV log analysis, statistics | Fast | Optional |
fd + rg | Searching across many log directories | Fast | Pre-installed in dev-shell |
GNU parallel | Splitting large files for parallel processing | N/A (orchestrator) | Optional |
When to Use What
Need to...
│
├─ Find lines matching a pattern
│ └─ rg (always fastest for text search)
│
├─ Extract specific fields from JSONL
│ └─ jq -r '[.field1, .field2] | @tsv'
│
├─ Count/aggregate over JSONL
│ └─ jq -sc 'group_by(.field) | map({key: .[0].field, n: length})'
│
├─ Search JSONL by value then format results
│ └─ rg '"error"' file.jsonl | jq -r '.message' (two-stage)
│
├─ Explore interactively with filtering/SQL
│ └─ lnav file.log
│
├─ Aggregate with pipeline syntax
│ └─ agrind '* | parse "* * *" as ts, level, msg | count by level'
│
├─ Extract columns from space-delimited logs
│ └─ awk '{print $1, $4, $7}' access.log
│
└─ Process CSV/TSV logs with headers
└─ mlr --csv filter '$status >= 400' then stats1 -a count -f status
JSONL Quick Reference
The most common format for structured logs. One JSON object per line, no trailing commas, no wrapping array.
Stream Filtering (line by line, constant memory)
jq -c 'select(.level == "error")' app.jsonl
jq -c 'select(.request.method == "POST")' app.jsonl
jq -c 'select(.level == "error" and .status >= 500)' app.jsonl
jq -c 'select(.tags | index("critical"))' app.jsonl
jq -c 'select(.stack_trace != null)' app.jsonl
jq -c 'select(.level != "debug")' app.jsonl
Field Extraction
jq -r '.message' app.jsonl
jq -r '[.timestamp, .level, .message] | @tsv' app.jsonl
jq -r '.error_code // "none"' app.jsonl
jq -r '.response.headers["content-type"] // "unknown"' app.jsonl
Aggregation (requires slurp: loads entire file)
jq -sc 'group_by(.level) | map({level: .[0].level, count: length})' app.jsonl
jq -sc '[.[].error_type] | group_by(.) | map({type: .[0], count: length}) | sort_by(-.count) | .[:10]' app.jsonl
jq -sc 'map(.duration_ms) | add' app.jsonl
jq -sc 'map(.duration_ms) | add / length' app.jsonl
jq -sc 'map(.duration_ms) | {min: min, max: max}' app.jsonl
Nested Extraction (agent logs, complex structures)
jq -c '.content[]? | select(.type == "tool_use") | .name' conversation.jsonl
jq -c '.content | fromjson' app.jsonl
jq -c '[.events[]? | .action]' app.jsonl
jq -c '.results[]? | select(.passed == false) | {test: .name, error: .message}' results.jsonl
Two-Stage Pipeline (rg for speed, jq for structure)
rg '"error"' app.jsonl | jq -r '[.timestamp, .message] | @tsv'
rg '"timeout"' app.jsonl | jq -sc 'length'
rg '"user_id":"u-123"' app.jsonl | jq -c '{ts: .timestamp, action: .action}'
Time-Range Filtering
jq -c 'select(.timestamp > "2026-03-08T10:00" and .timestamp < "2026-03-08T11:00")' app.jsonl
jq -c --arg cutoff "$(date -d '30 minutes ago' +%s)" 'select((.timestamp | sub("\\.[0-9]+Z$"; "Z") | fromdate) > ($cutoff | tonumber))' app.jsonl
jq -r '.timestamp | split("T")[1] | split(":")[0]' app.jsonl | sort | uniq -c
Cross-File Join
jq -r '.request_id' errors.jsonl | while read id; do
rg "\"$id\"" responses.jsonl | jq -c '{id: .request_id, status: .status}'
done
jq -r '.request_id' errors.jsonl | sort -u > /tmp/error_ids.txt
rg -Ff /tmp/error_ids.txt responses.jsonl | jq -c '{id: .request_id, status: .status}'
jq --slurpfile lookup <(jq -sc 'map({(.id): .}) | add' lookup.jsonl) \
'. + ($lookup[0][.ref_id] // {})' main.jsonl
Plain Text Log Patterns
Pattern Search with Context
rg -B5 -A5 "OutOfMemoryError" app.log
rg -l "FATAL" /var/log/
rg -c "ERROR" /var/log/*.log | sort -t: -k2 -rn
rg -U "Exception.*\n(\s+at .*\n)+" app.log
Column Extraction with awk
awk '{print $9}' access.log | sort | uniq -c | sort -rn
awk '$0 >= "Mar 8 10:00" && $0 <= "Mar 8 11:00"' syslog
awk '{sum += $11; n++} END {print sum/n}' access.log
awk '$9 >= 500 {print $7, $11"ms"}' access.log
Live Monitoring
tail -f app.log | rg --line-buffered "ERROR"
tail -f app.jsonl | jq --unbuffered -r '[.timestamp, .level, .message] | @tsv'
tail -f /var/log/service-*.log | rg --line-buffered "error|warn"
Timeline Reconstruction
Extracting and Sorting by Timestamp
sort -t' ' -k1,2 service-a.log service-b.log > timeline.log
jq -sc 'sort_by(.timestamp)[]' combined.jsonl > sorted.jsonl
jq -r '.timestamp' app.jsonl | awk '
NR > 1 {
cmd = "date -d \"" prev "\" +%s"; cmd | getline t1; close(cmd)
cmd = "date -d \"" $0 "\" +%s"; cmd | getline t2; close(cmd)
gap = t2 - t1
if (gap > 5) print gap "s gap before " $0
}
{ prev = $0 }
'
jq -sc '{start: .[0].timestamp, end: .[-1].timestamp}' app.jsonl
Calculating Durations Between Events
jq -sc '
group_by(.request_id) |
map(
(map(select(.event == "start")) | .[0].timestamp) as $start |
(map(select(.event == "end")) | .[0].timestamp) as $end |
{id: .[0].request_id, start: $start, end: $end}
)
' events.jsonl
jq -sc '
sort_by(.timestamp) |
[range(1; length) | {
from: .[.-1].event,
to: .[.].event,
gap: ((.[.].ts_epoch) - (.[.-1].ts_epoch))
}] |
sort_by(-.gap) | .[0]
' events.jsonl
Cross-Log Correlation
By Correlation ID
fd -e jsonl . /var/log/services/ -x rg "\"req-abc-123\"" {}
fd -e jsonl . /var/log/services/ -x rg "\"req-abc-123\"" {} \; | jq -sc 'sort_by(.timestamp)[] | [.timestamp, .service, .event] | @tsv'
By Timestamp Window
TARGET="2026-03-08T14:23:15"
jq -c --arg t "$TARGET" '
select(
.timestamp > ($t | sub("15$"; "13")) and
.timestamp < ($t | sub("15$"; "17"))
)
' other-service.jsonl
By Session/User
fd -e jsonl . /var/log/ -x rg "\"user-42\"" {} \; |
jq -sc 'sort_by(.timestamp)[] | [.timestamp, .service, .action] | @tsv'
Large File Strategies
Search Recent Only
tail -n 10000 huge.log | rg "pattern"
tail -n 5000 huge.jsonl | jq -c 'select(.level == "error")'
Split for Parallel Processing
split -l 100000 huge.jsonl /tmp/chunk_
fd 'chunk_' /tmp/ -x jq -c 'select(.level == "error")' {} > errors.jsonl
split -l 100000 huge.jsonl /tmp/chunk_
ls /tmp/chunk_* | parallel 'jq -c "select(.level == \"error\")" {} >> /tmp/errors.jsonl'
Streaming for Huge Single JSON
jq --stream 'select(.[0][0] == "results" and .[0][-1] == "status") | .[1]' huge.json
jq -cn --stream 'fromstream(1 | truncate_stream(inputs))' huge-array.json
Two-Stage Always
rg '"error"' huge.jsonl | jq -r '.message'
jq -r 'select(.level == "error") | .message' huge.jsonl
Search Across Directories
Multi-Directory Patterns
fd -e jsonl . trials/ -x rg -l '"error"' {}
fd -e jsonl . trials/ -x bash -c 'echo "$(rg -c "\"error\"" "$1" 2>/dev/null || echo 0) $1"' _ {}
fd -e jsonl . trials/ -x jq -c 'select(.level == "error") | {file: input_filename, msg: .message}' {}
for dir in trials/*/; do
total=$(wc -l < "$dir/results.jsonl")
errors=$(rg -c '"error"' "$dir/results.jsonl" 2>/dev/null || echo 0)
echo -e "$dir\t$total\t$errors"
done | column -t -N DIR,TOTAL,ERRORS
Common Gotchas
| Gotcha | Why It Hurts | Fix |
|---|
jq -s on huge files loads everything into memory | OOM crash or swap thrashing on files over ~500MB | Use streaming: rg prefilter, jq --stream, or split + parallel |
| JSONL with embedded newlines in string values | Line-by-line tools (rg, awk, head) split a single record across lines | Use jq -c to re-compact, or jq -R 'fromjson?' to skip malformed lines |
| rg matches JSON keys, not just values | rg "error" matches {"error_count": 0} which is not an error | Use rg '"level":"error"' or pipe to jq 'select(.level == "error")' |
| Timezone mismatches in timestamp comparisons | Events appear out of order or time ranges miss data | Normalize to UTC before comparing: `jq '.timestamp |
| Unicode and escape sequences in log messages | jq chokes on invalid UTF-8 or double-escaped strings | Prefilter with rg -a (binary mode), or use jq -R for raw strings |
| Inconsistent JSON schemas across log lines | jq errors on lines missing expected fields | Use // operator for defaults: .field // "missing" and ? for optional: .arr[]? |
Forgetting -c flag with jq on JSONL | jq pretty-prints each line, output is no longer valid JSONL | Always use jq -c when output feeds into another JSONL consumer |
| tail -f with jq buffering | Output appears delayed or not at all | Use jq --unbuffered or stdbuf -oL jq |
| Sorting JSONL by timestamp without slurp | sort command does lexicographic sort on whole lines, not by field | Either jq -sc 'sort_by(.timestamp)[]' or extract timestamp prefix first |
| Assuming log files are complete | Logs may be rotated, compressed, or still being written | Check for .gz rotated files: |
Reference Files
| File | Contents | Lines |
|---|
references/jsonl-patterns.md | JSONL extraction, aggregation, transformation, comparison, and performance patterns | ~700 |
references/analysis-workflows.md | Agent conversation analysis, application log analysis, benchmark result parsing, cross-directory workflows | ~600 |
references/tool-setup.md | Installation and configuration for jq, lnav, angle-grinder, rg, awk, GNU parallel, Miller | ~450 |
See Also
- data-processing -- JSON/YAML/TOML processing with jq and yq
- debug-ops -- Systematic debugging methodology, log-based debugging section
- monitoring-ops -- Production observability, alerting, dashboards
- file-search -- Finding files with fd, searching code with rg