The situation
You have joined a small online shop as its first developer. Last night the checkout page failed for some customers. Your manager sends you the web server's access log and asks three things:
- How many requests came in, and from how many different visitors?
- Which pages are visited most?
- Which requests failed with a server error, and can you keep those lines in a file for later?
Next week the same question will arrive with a different log file. So you will not answer by hand. You will write a script, a saved list of commands, that produces the same report for any log in the same format. Every skill from this module appears: navigating, creating files, pipes, grep, variables and permissions.
This is a real task. On-call engineers write exactly this kind of script on their first week.
The log format
Each line is one request. Fields are separated by single spaces:
IP TIMESTAMP METHOD PATH STATUS BYTES| Field | Example | Meaning |
|---|---|---|
| IP | 203.0.113.5 | The visitor's address. |
| TIMESTAMP | 2026-11-04T09:00:01 | When the request arrived. |
| METHOD | GET | GET reads a page, POST sends a form. |
| PATH | /checkout | The page asked for. |
| STATUS | 500 | Result code: 2xx fine, 3xx redirect, 4xx the visitor's mistake, 5xx the server's fault. |
| BYTES | 120 | Size of the reply. |
Remember the cutting tool from lesson four: cut -d' ' -f4 takes field 4 (the path), and -f5 takes the status.
Step 0: set up the workspace
Create a clean folder and the log file. Type this exactly. The here-document keeps every line in place.
$ cd ~/forge-lab
$ mkdir -p log-detective
$ cd log-detective
$ cat > access.log <<'EOF'
203.0.113.5 2026-11-04T09:00:01 GET /home 200 512
198.51.100.7 2026-11-04T09:00:03 GET /products 200 2048
203.0.113.5 2026-11-04T09:00:09 GET /products/42 200 1024
192.0.2.44 2026-11-04T09:01:15 POST /login 302 0
192.0.2.44 2026-11-04T09:01:16 GET /account 200 900
198.51.100.7 2026-11-04T09:02:02 GET /cart 200 640
203.0.113.5 2026-11-04T09:02:30 POST /checkout 500 120
198.51.100.9 2026-11-04T09:03:01 GET /home 200 512
198.51.100.9 2026-11-04T09:03:05 GET /missing 404 80
203.0.113.5 2026-11-04T09:03:40 POST /checkout 500 120
192.0.2.44 2026-11-04T09:04:11 GET /products 200 2048
198.51.100.7 2026-11-04T09:04:50 GET /home 200 512
198.51.100.9 2026-11-04T09:05:20 GET /old-page 404 80
203.0.113.77 2026-11-04T09:05:59 GET /products 200 2048
203.0.113.77 2026-11-04T09:06:10 POST /checkout 503 90
192.0.2.44 2026-11-04T09:06:45 GET /home 200 512
198.51.100.7 2026-11-04T09:07:30 GET /products 200 2048
203.0.113.5 2026-11-04T09:08:02 GET /home 200 512
198.51.100.9 2026-11-04T09:08:44 GET /cart 200 640
203.0.113.77 2026-11-04T09:09:00 GET /products 200 2048
EOF
$ wc -l access.log
20 access.logIf wc -l does not print 20, a line was lost when you pasted. Fix it before going on.
Before you write the script, check the expected answers independently. This Python program computes them from the same text. Having a second source of truth is a habit worth keeping: if your shell pipeline disagrees with it, one of the two has a bug.
total: 20
unique ips: 5
by status: [('200', 14), ('302', 1), ('404', 2), ('500', 2), ('503', 1)]
top path counts: [5, 5, 3]
5xx lines: 3Keep those numbers: 20 requests, 5 visitors, 14 successes, 3 server errors.
The specification
Write a script named report.sh in the log-detective folder.
Usage: ./report.sh LOGFILE
Behaviour:
- If no argument is given, print
usage: ./report.sh LOGFILEto standard error (not standard output) and exit with code2. - If the file does not exist, print
error: no such file: NAMEto standard error and exit with code1. - Otherwise print a report to standard output, in this order:
- a heading line
== Report for NAME == Total requests: NUnique visitors: N(number of different IP addresses)- a blank line, the line
Requests per status code (count, code):, then one line per status code, most frequent first - a blank line, the line
Top K paths (count, path):, then the K most-requested paths, most frequent first - a blank line, then
Server errors: N (saved in errors.txt)
- a heading line
Kdefaults to3, and can be changed with an environment variable:TOP_N=5 ./report.sh access.loglists five paths.- Write every line with a status in the 500 to 599 range to a file
errors.txtin the current folder, replacing any older copy. Set that file's permissions so that only you can read or write it. - The script must be executable, and must start with a shebang line.
- The script must not modify the log file.
Expected output
For the log above, with no TOP_N set, the report is:
== Report for access.log ==
Total requests: 20
Unique visitors: 5
Requests per status code (count, code):
14 200
2 500
2 404
1 503
1 302
Top 3 paths (count, path):
5 /products
5 /home
3 /checkout
Server errors: 3 (saved in errors.txt)Two details about tied counts. Where counts tie (500 and 404 both have 2, /products and /home both have 5), the order of the tied lines may differ on your machine, because it depends on how sort compares text in your locale setting. Either order is correct. Also, the width of the count column depends on uniq -c; do not try to match the spaces exactly.
errors.txt must hold exactly these three lines, and ls -l errors.txt must start with -rw-------:
203.0.113.5 2026-11-04T09:02:30 POST /checkout 500 120
203.0.113.5 2026-11-04T09:03:40 POST /checkout 500 120
203.0.113.77 2026-11-04T09:06:10 POST /checkout 503 90Error behaviour
$ ./report.sh
usage: ./report.sh LOGFILE
$ echo $?
2
$ ./report.sh nope.log
error: no such file: nope.log
$ echo $?
1
$ ./report.sh nope.log 2> /dev/null
$ echo $?
1The last command proves the message goes to standard error: with 2> /dev/null nothing is shown, but the exit code is still 1.
Requirements checklist
Copy this list into a note and tick the boxes as you verify each one.
-
report.shexists in~/forge-lab/log-detectiveand starts with#!/usr/bin/env bash -
ls -l report.shshows anxfor the owner (it runs with./report.sh) - Running with no argument prints the usage line to stderr and exits with 2
- Running with a missing file prints the error to stderr and exits with 1
- Total requests is
20for the sample log - Unique visitors is
5for the sample log - Status codes are listed with counts, highest first
- The top paths section lists 3 paths by default
-
TOP_N=5 ./report.sh access.loglists 5 paths and the heading saysTop 5 paths -
errors.txtcontains exactly the three 5xx lines -
ls -l errors.txtstarts with-rw------- - The log file is unchanged afterwards (
wc -l access.logstill says 20) - You tested at least one extra log of your own (see below)
How you will be graded
| Level | What it looks like |
|---|---|
| 60% | The script runs and prints total requests and the status-code table correctly for the sample log. It may be missing the error handling, TOP_N, or the private errors.txt. Some commands may be hard-coded to access.log instead of using the argument. |
| 80% | Everything in the specification works for the sample log: correct report, errors.txt with the right lines and mode 600, usage and missing-file errors with the right exit codes and messages on stderr, and TOP_N honoured. Variables are quoted. |
| 100% | All of the above, plus: it works on a log with zero lines and on a path containing a space, the log is never modified, the script uses set -u or equivalent care with unset variables, you tested a second log of your own with expected numbers worked out beforehand, and you can explain each pipeline stage aloud. At least one extension below is done. |
Hints
Try each step yourself for ten minutes before opening a hint. Each hint is hidden so you can choose how much help you want.
Hint 1: start with the pipelines, not the script
Do not open an editor yet. At the prompt, with access.log in the folder, find each answer with a one-line pipeline.
- Total requests:
wc -l < access.log - Unique visitors: take field 1 with
cut -d' ' -f1, thensort -u, then count lines. - Status counts: take field 5, then
sort | uniq -c | sort -rn. - Top paths: take field 4, then the same counting pipeline, then
head -n 3.
When each pipeline prints what you expect (compare with the Python cross-check), only then copy it into a file.
Hint 2: the 5xx filter
The status is field 5, and a 5xx code is three digits starting with 5. grep -E ' 5[0-9][0-9] ' access.log matches the code surrounded by spaces. The spaces matter: without them, a byte count such as 5120 or a path containing 500 could match by accident. grep -c counts the matches, but you also need the lines themselves, so redirect with > into errors.txt.
Hint 3: variables, defaults and the argument
The first argument to a script is $1. Give variables readable names at the top: log="$1". A default for an environment variable uses ${TOP_N:-3}, which means "the value of TOP_N, or 3 if it is unset or empty". Always quote variables that might contain spaces: "$log".
If you use set -u (an error on unset variables), $1 itself is unset when no argument is given. Write ${1:-} to say "the first argument, or empty if missing".
Hint 4: errors go to stderr, and exit codes
To send a message to stderr, redirect the echo: echo "usage: $0 LOGFILE" >&2. The >&2 means "send stdout to wherever stderr goes". exit 2 stops the script with that code. $0 is the name the script was run as, which is ./report.sh. Test for a missing argument with [ -z "$log" ] (true if empty) and a missing file with [ ! -f "$log" ] (true if not a regular file).
Hint 5: making it executable and private
chmod +x report.sh lets you run it. For errors.txt, chmod 600 errors.txt gives read and write to you alone. Do the chmod after the file is created. Verify with ls -l: -rw------- for the data file and -rwxr-xr-x (or similar) for the script.
Hint 6: pipelines inside echo
To print a number after a label, use command substitution: echo "Total requests: $(wc -l < "$log")". Notice the quotes nested inside $( ): they are allowed, and they protect a log path with spaces. On macOS, wc -l pads the number with spaces, so Total requests: 20 is normal there.
Reference solution
Try before you look. If you peek, close it and rewrite from memory after reading.
Solution: report.sh
#!/usr/bin/env bash
# Usage: ./report.sh LOGFILE
# Set TOP_N to change how many paths are listed (default 3).
set -u
log="${1:-}"
top="${TOP_N:-3}"
if [ -z "$log" ]; then
echo "usage: $0 LOGFILE" >&2
exit 2
fi
if [ ! -f "$log" ]; then
echo "error: no such file: $log" >&2
exit 1
fi
echo "== Report for $log =="
echo "Total requests: $(wc -l < "$log")"
echo "Unique visitors: $(cut -d' ' -f1 "$log" | sort -u | wc -l)"
echo
echo "Requests per status code (count, code):"
cut -d' ' -f5 "$log" | sort | uniq -c | sort -rn
echo
echo "Top $top paths (count, path):"
cut -d' ' -f4 "$log" | sort | uniq -c | sort -rn | head -n "$top"
# Server errors (status 5xx) go to a private file.
grep -E ' 5[0-9][0-9] ' "$log" > errors.txt
chmod 600 errors.txt
echo
echo "Server errors: $(wc -l < errors.txt) (saved in errors.txt)"Create it and run it:
$ nano report.sh # paste the script, Ctrl+O, Enter, Ctrl+X
$ chmod +x report.sh
$ ./report.sh access.log
== Report for access.log ==
Total requests: 20
Unique visitors: 5
Requests per status code (count, code):
14 200
2 500
2 404
1 503
1 302
Top 3 paths (count, path):
5 /products
5 /home
3 /checkout
Server errors: 3 (saved in errors.txt)
$ ls -l errors.txt
-rw------- 1 anisha anisha 165 Nov 4 15:30 errors.txt
$ TOP_N=1 ./report.sh access.log | grep -A1 Top
Top 1 paths (count, path):
5 /productsSolution walk-through: why each line is there
set -umakes the script stop with an error if it reads an unset variable, which catches typos such as$lgo.${1:-}and${TOP_N:-3}give safe defaults. The first lets the script run its own usage check even withset -u. The second makesTOP_Noptional.- The usage message goes to stderr with
>&2, and the exit codes (2 for bad usage, 1 for a missing file) let other scripts react to failures, which is the same idea as the exit codes ofgrep. wc -l < "$log"prints only the number, because with<the program receives input on stdin and has no file name to print.cut -d' ' -f1 | sort -u | wc -lcounts distinct visitors: take the field, deduplicate, count lines.sort -uis the short form ofsort | uniq.- The status table and the top-paths table use the counting pipeline from lesson four. They differ only in the field number.
head -n "$top"keepsKlines. Quoting keeps the value as one word.- The
grep -E ' 5[0-9][0-9] 'pattern includes the surrounding spaces so it only matches the status field. >replaces any olderrors.txt, which is what "replacing any older copy" asked for. Running twice never doubles the lines.chmod 600runs after the file exists, so it is never readable by others even for a moment longer than needed. (Strictly, a umask of077set before creating the file would remove even that moment.)
Test it like a professional
A script that works once on the sample is a demo. A script you trust has been tried on awkward input. Do these checks and record the result of each.
- Empty file:
: > empty.logcreates a zero-byte file../report.sh empty.logshould printTotal requests: 0andServer errors: 0with empty tables. It must not crash. - A name with a space:
cp access.log "my log.txt", then./report.sh "my log.txt". If the output breaks, you forgot quotes somewhere. - A second log: write a five-line log of your own. Work out the expected answers by hand before you run the script. Then compare. If they differ, decide who is wrong, you or the script.
- Run it twice:
errors.txtshould have the same three lines the second time, not six. - From another folder:
cd ..then./log-detective/report.sh log-detective/access.log. It still works, buterrors.txtappears in the folder you ran it from. Think about whether that is what you want, and say how you would change it (a hint: write the file next to the log, usingdirname).
Here is a tiny second log to try, with answers you can verify:
$ printf '10.0.0.1 2026-11-04T10:00:00 GET /a 200 1\n10.0.0.1 2026-11-04T10:00:01 GET /a 200 1\n10.0.0.2 2026-11-04T10:00:02 GET /b 500 1\n' > tiny.log
$ ./report.sh tiny.log
== Report for tiny.log ==
Total requests: 3
Unique visitors: 2
Requests per status code (count, code):
2 200
1 500
Top 3 paths (count, path):
2 /a
1 /b
Server errors: 1 (saved in errors.txt)Three requests, two visitors, one server error: that matches what you can read off the three lines by eye.
Extensions (for the top marks)
Choose at least one.
- Busiest minute. The timestamp's hour and minute are characters 12 to 16 of field 2.
cut -d' ' -f2 | cut -c12-16extracts them. Add a lineBusiest minute: HH:MM (N requests)using the counting pipeline. - Error rate. Print
Error rate: 15%(3 of 20) using shell arithmetic:echo $(( 100 * errors / total )). Handle an empty log without dividing by zero. - Bandwidth. Sum the bytes (field 6). Hint:
cut -d' ' -f6 | paste -sd+ | bcjoins the numbers with plus signs and calculates. Print the total in bytes. - Many logs. Allow several arguments (
./report.sh a.log b.log) and print one report for each. Look up the shell'sfor log in "$@"; do ... doneloop. - Install it. Move
report.shinto~/bin, add~/binto yourPATHin~/.bashrc, and run it from any folder with the bare commandreport.sh access.log. Explain in two sentences why the folder must be onPATHand what thexbit is for.
Reflect
When you finish, write three or four sentences in a NOTES.txt file in the folder (using echo and >>, as practice). Answer: which pipeline stage was hardest to get right, what mistake did you make first, and which flag or command from this module surprised you most? Re-reading it in a month is a quick measure of what you learnt.
What you practised
- Navigation and files: a project folder, a here-document, a script file.
- Pipes and filters:
cut,sort,uniq,wc,head,grep -E. - Streams and exit codes: errors on stderr, meaningful exit statuses.
- Variables: arguments, defaults, and an environment variable that changes behaviour.
- Permissions: an executable script and a private output file.