Centralised logging is great until the incident is about the logging pipeline, or the question is too specific for the dashboard someone built last year. At that point an on-call engineer with SSH access and a few text-processing commands can answer "which clients are getting 502s, since when, on which endpoint?" faster than anyone can write a query in a web UI.
This post is the toolkit I keep in my head: a small set of commands and patterns that cover most log questions. Every example runs against the same two sample files, so you can see exactly what each step does.
The sample logs
An nginx access log in the default combined format plus request time:
203.0.113.7 - - [06/Jun/2025:09:14:02 +0000] "GET /api/orders?page=2 HTTP/1.1" 200 1532 "-" "okhttp/4.12" 0.084
198.51.100.23 - - [06/Jun/2025:09:14:02 +0000] "POST /api/checkout HTTP/1.1" 502 166 "-" "Mozilla/5.0" 30.001
203.0.113.7 - - [06/Jun/2025:09:14:03 +0000] "GET /api/orders?page=3 HTTP/1.1" 200 1488 "-" "okhttp/4.12" 0.091
192.0.2.55 - - [06/Jun/2025:09:14:05 +0000] "POST /api/checkout HTTP/1.1" 502 166 "-" "Mozilla/5.0" 30.002
And an application log with one JSON object per line:
{"ts":"2025-06-06T09:14:02.118Z","level":"error","msg":"payment timeout","order_id":"o_9921","provider":"acme-pay","duration_ms":30000}
{"ts":"2025-06-06T09:14:02.410Z","level":"info","msg":"order listed","user_id":"u_17","duration_ms":61}
grep: find the lines
grep ' 502 ' access.log | head # lines containing a 502 status
grep -c ' 502 ' access.log # just the count
grep -E '" (5[0-9]{2}) ' access.log | wc -l # all 5xx
grep -v 'healthcheck' access.log # everything except health checks
grep -i 'timeout' app.log # case-insensitive
grep -B2 -A5 'OutOfMemory' app.log # context before and after
Two habits save time. First, anchor patterns tightly: ' 502 ' with spaces avoids matching a byte size
of 1502. Second, use zgrep for rotated, compressed files, so you do not unpack gigabytes:
zgrep -h ' 502 ' /var/log/nginx/access.log.*.gz | wc -l
When the log is large, grep -F for fixed strings is noticeably faster than a regex, and LC_ALL=C
speeds up most of these tools on plain ASCII logs.
awk: pick and compute fields
awk splits each line on whitespace into $1, $2 and so on. In the combined format, the client IP is
$1, the status $9, the bytes $10 and our request time is the last field, $NF.
awk '{print $9}' access.log | sort | uniq -c | sort -rn
2 502
2 200
Requests slower than one second, with time, path and duration:
awk '$NF > 1 {print $4, $7, $NF}' access.log
[06/Jun/2025:09:14:02 /api/checkout 30.001
[06/Jun/2025:09:14:05 /api/checkout 30.002
Average and maximum request time per status:
awk '{sum[$9]+=$NF; n[$9]++; if ($NF>max[$9]) max[$9]=$NF}
END {for (s in sum) printf "%s avg=%.3f max=%.3f n=%d\n", s, sum[s]/n[s], max[s], n[s]}' access.log
200 avg=0.088 max=0.091 n=2
502 avg=30.002 max=30.002 n=2
The 30-second durations on every 502 point at an upstream timeout, which is a far more useful lead than "some 502s".
sort and uniq: count and rank
The pattern sort | uniq -c | sort -rn is the single most useful pipeline in log analysis. It turns any
column into a ranked frequency table.
Top client IPs by request count:
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head
Top paths without query strings:
awk '{split($7, p, "?"); print p[1]}' access.log | sort | uniq -c | sort -rn | head
Errors per minute, to see when a problem started:
grep ' 502 ' access.log | awk '{print substr($4, 2, 17)}' | uniq -c
2 06/Jun/2025:09:14
uniq only collapses adjacent duplicates, so it needs sorted input, except when the input is already
in time order and you want counts per time bucket, as in the last example.
cut and tr: quick slicing
For fixed delimiters, cut is simpler than awk:
cut -d' ' -f1 access.log | sort -u | wc -l # unique client IPs
cut -d'"' -f6 access.log | sort | uniq -c # user agents (6th field when splitting on quotes)
tr -s ' ' < messy.txt # squeeze repeated spaces before cutting
jq: structured JSON logs
jq reads JSON and lets you filter and reshape it. With one object per line, every example below
processes the file as a stream:
jq -c 'select(.level == "error")' app.log
jq -r 'select(.level == "error") | [.ts, .msg, .order_id] | @tsv' app.log
jq -r 'select(.duration_ms > 1000) | .provider' app.log | sort | uniq -c | sort -rn
1 acme-pay
Group and count inside jq with slurp mode (-s), which reads the whole file into memory; fine for
thousands of lines, not for gigabytes:
jq -s 'map(select(.level=="error")) | group_by(.msg) | map({msg: .[0].msg, n: length})' app.log
If some lines are not valid JSON, for example a stack trace printed raw, filter first:
grep '^{' app.log | jq -c 'select(.level=="error")'
Following a live log
tail -F /var/log/nginx/access.log | grep --line-buffered ' 502 '
-F keeps following through log rotation. --line-buffered makes grep print each match immediately
instead of waiting to fill a buffer, which matters when you pipe into another command.
Putting it together: a real question
Question: during the checkout incident, which clients were affected, how many times, and when did it start and stop?
zgrep -h 'POST /api/checkout' /var/log/nginx/access.log* \
| awk '$9 >= 500 {print $1, substr($4, 2, 17)}' \
| sort -k2 \
| awk '{c[$1]++; if (!first) first=$2; last=$2} END {print "first", first, "last", last; for (ip in c) print c[ip], ip}' \
| sort -rn | head
One pipeline gives the time window and the most affected clients, which is what the incident channel asked for.
Common mistakes
| Mistake | Why it hurts | Do this instead |
|---|---|---|
cat file | grep on huge files |
Extra process, no benefit | grep pattern file |
Unanchored patterns like 502 |
Matches sizes and IDs | Include delimiters: ' 502 ' |
uniq -c on unsorted input |
Counts are split | sort | uniq -c |
| Assuming field numbers in custom formats | Wrong column silently | Check log_format first, count fields with awk '{print NF}' |
jq -s on gigabyte logs |
Loads everything in memory | Stream with select, pre-filter with grep |
| Running heavy pipelines on a struggling server | Adds load during an incident | Copy logs elsewhere, or use nice |
A cheat sheet to keep
count lines grep -c PATTERN file
ranked frequency ... | sort | uniq -c | sort -rn | head
field N awk '{print $N}' last field: $NF
numeric filter awk '$NF > 1'
per-minute buckets awk '{print substr($4,2,17)}' | uniq -c
compressed logs zgrep -h PATTERN file*.gz
JSON filter jq -c 'select(.level=="error")'
JSON to TSV jq -r '[.a,.b] | @tsv'
live follow tail -F file | grep --line-buffered PATTERN
Key takeaways
grepfinds lines,awkextracts and computes fields,sort | uniq -c | sort -rnranks anything.- Anchor patterns and know your log format's field positions before trusting numbers.
jqwithselecthandles structured logs as a stream.- Use
zgrepandtail -Fto cover rotated and live logs. - Build the answer in small steps, checking each stage's output before adding the next.
References
- GNU grep manual: https://www.gnu.org/software/grep/manual/grep.html
- GNU awk user's guide: https://www.gnu.org/software/gawk/manual/gawk.html
- GNU coreutils manual (sort, uniq, cut, tr): https://www.gnu.org/software/coreutils/manual/coreutils.html
- jq manual: https://jqlang.org/manual/
- nginx log format documentation: https://nginx.org/en/docs/http/ngx_http_log_module.html
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.