Level 1 capstone: solution¶
Level 1 · Capstone solution · Back to the capstone questions
This page answers all ten questions about the generated access.log, explains each pipeline stage by stage, and points out the traps. Try every question yourself first. If your pipeline looks different but prints the same numbers, it is a correct answer: there are always several good ways to do this.
Before you start¶
Run every command in the directory with the generated log, and confirm it is the expected file:
If the checksum differs, regenerate the file from the capstone page; otherwise your numbers will not match.
How the pipelines are built¶
Every answer follows the same method, which is worth more than any individual pipeline:
- Extract the one thing you care about (an IP, a path, an hour) from each line.
- Filter to the lines that matter (errors, 404s, one client), testing a specific field.
- Group and count, either with
sort | uniq -cor with an awk associative array. - Rank and trim with
sort -rn | head.
Build each pipeline one stage at a time and look at the output after every |. Add | head -n 3 while you are experimenting.
Lines with equal counts may appear in a different order on your machine if your language settings differ. The counts themselves will match.
Question 1: Size of the problem¶
5,000 requests from 113 distinct IPs.
| Stage | Output |
|---|---|
wc -l < access.log |
The number of lines. The < redirection hides the file name, so you get just the number. |
cut -d' ' -f1 access.log |
One IP per line, 5,000 lines, in log order. |
sort -u |
Sorted, with duplicates removed: one line per distinct IP. |
wc -l |
Counts those lines. |
sort | uniq | wc -l gives the same result. uniq alone would not, because it only collapses adjacent duplicates.
Question 2: Top talkers¶
283 192.0.2.77
264 203.0.113.10
231 198.51.100.34
147 198.51.100.112
140 198.51.100.120
135 192.0.2.4
133 203.0.113.233
132 198.51.100.96
111 198.51.100.196
107 198.51.100.23
This is the frequency-count idiom. Here is what flows through each pipe:
| Stage | Sample output | What it does |
|---|---|---|
cut -d' ' -f1 access.log |
192.0.2.157203.0.113.203192.0.2.88 |
Field 1 of each line, split on single spaces |
sort |
192.0.2.109192.0.2.109192.0.2.109 |
Identical IPs become adjacent |
uniq -c |
9 192.0.2.10912 192.0.2.11322 192.0.2.119 |
One line per run, prefixed with its length |
sort -rn |
283 192.0.2.77264 203.0.113.10 |
Numeric sort on the leading count, biggest first |
head |
(the 10 lines above) | Keep the first 10 |
The top two are not people. Question 9 will show that 192.0.2.77 is Googlebot and 203.0.113.10 is a Python script. Number 10, 198.51.100.23, comes back in question 7.
Question 3: Popular pages¶
532 /
512 /api/products
346 /static/css/main.css
340 /static/js/app.js
334 /products
279 /search
273 /login
271 /api/orders
252 /images/logo.png
206 /api/cart
| Stage | Sample output | What it does |
|---|---|---|
cut -d' ' -f7 |
/api/products?page=3/search?q=webcam |
The request path, with any query string |
cut -d'?' -f1 |
/api/products/search |
Everything before the first ?. Lines without a ? pass through unchanged. |
sort | uniq -c | sort -rn | head |
(above) | Frequency count |
Without the second cut, /search?q=laptop, /search?q=monitor, and the others would each be counted separately, and /search would drop out of the top 10.
Individual product pages (/products/1 to /products/60) are each counted separately, so none of them makes the list. Counting them as one group would need another transformation, for example sed -E 's#/products/[0-9]+#/products/N#'.
Question 4: Status codes¶
cut -d' ' -f9 access.log | sort | uniq -c | sort -rn
awk '$9 >= 400 {err++} END {printf "%d of %d requests failed (%.1f%%)\n", err, NR, 100 * err / NR}' access.log
6.7% of requests failed. Most failures are 404s.
The awk program, piece by piece:
| Part | Meaning |
|---|---|
$9 >= 400 |
Pattern: the status field, compared as a number |
{err++} |
Action for matching lines: count them |
END {...} |
After the last line |
NR |
In END, the total number of lines read |
%.1f%% |
A number with one decimal place, then a literal % |
A quick view by status class (2xx, 3xx, 4xx, 5xx) takes the first digit:
Trap: matching text instead of fields
grep -c ' 404 ' access.log happens to give the right answer here, but grep -c 404 would also count any line with 404 in a path, a byte count, or a timestamp. $9 == 404 tests only the status field.
Question 5: Traffic by hour¶
59 00
35 01
74 02
65 03
33 04
63 05
119 06
174 07
245 08
290 09
307 10
380 11
350 12
308 13
321 14
321 15
284 16
263 17
214 18
266 19
281 20
268 21
186 22
94 23
| Stage | Sample output | What it does |
|---|---|---|
cut -d: -f2 |
00 |
Splits on colons. The first colon on each line is the one after the year in [21/Sep/2026:00:00:11, so field 2 is the hour. |
uniq -c |
59 00 |
Counts runs. The log is in time order, so each hour is already one contiguous run and no sort is needed. |
The busiest hour:
11:00 to 11:59 was busiest, with 380 requests.
The odd hours are 02 and 03: 74 and 65 requests, twice as many as 01 and 04 on either side. A real shop is quietest at that time of night. The extra traffic is the vulnerability scanner you will identify in question 7, which was active only between 02:00 and 03:59.
Trap: cut -d: with IPv6
This only works because IPv4 addresses contain no colons. An IPv6 client such as 2001:db8::1 would shift every field. awk '{print substr($4, 14, 2)}' takes the hour from a fixed position inside the timestamp field instead, which is robust to that.
Question 6: The outage¶
Server errors per hour:
The outage was in the 14:00 hour: 27 server errors, against a background of 0 to 3 per hour.
| Stage | What it does |
|---|---|
$9 >= 500 |
Keep only 5xx responses, tested on the status field |
substr($4, 14, 2) |
In [21/Sep/2026:14:02:38, the hour is at characters 14 and 15 |
sort | uniq -c |
Count per hour. Hours with no errors simply do not appear. |
Which endpoints failed, and how:
9 502 /api/products
7 502 /api/orders
2 503 /api/orders
2 503 /api/cart
2 502 /api/cart
2 500 /static/css/main.css
1 500 /products
1 500 /api/products
1 500 /api/cart
Printing two fields, $9 and $7, and counting the pairs groups by status and path at once. The pattern is clear: almost all errors are 502 Bad Gateway and 503 Service Unavailable on /api/* endpoints. Those codes mean nginx could not get an answer from the backend application behind it. The few 500s on other pages are the normal background noise you saw in other hours.
When did it start and stop?
substr($4, 14, 8) is HH:MM:SS. sed -n '1p;$p' prints only the first and last lines, which are the earliest and latest error, because the log is in time order. The API backend failed intermittently from about 14:02 to 14:56.
A useful follow-up for the incident report is how many distinct clients saw an error:
Trap: a regex that matches minutes as well as hours
A natural first attempt is awk '$9 >= 500 && $4 ~ /:14:/'. It finds 28 lines, not 27:
:14: also matches minute 14 of any hour, here 00:14:07. Extracting the hour by position, or anchoring the regex as /:14:[0-9][0-9]:[0-9][0-9]$/, avoids it. This is the "leftmost match" lesson from Text processing in a real setting: always ask what else your pattern could match.
Question 7: Not found¶
Top 404 paths:
24 /xmlrpc.php
21 /products/56
21 /.git/config
19 /products/54
18 /products/59
18 /products/58
18 /admin
17 /.env
16 /products/52
15 /products/55
Two different stories are mixed here: product pages above /products/50 that no longer exist, and paths like /xmlrpc.php, /.git/config, /admin, and /.env that this shop never had.
Who generates the 404s:
198.51.100.23 caused 107 of the 262 404s. Everything it requested:
It is a vulnerability scanner. It probes for WordPress (/wp-login.php, /xmlrpc.php), exposed secrets (/.env, /.git/config), and admin panels. Every one of its requests got a 404, so it found nothing. Its user agent, zgrab, is a well-known internet scanning tool.
When was it active?
Only between 02:00 and 03:59, which explains the night-time bump in question 5. The second-biggest 404 source, 192.0.2.77, is Googlebot following links to removed product pages, a sign that those pages should redirect or be removed from the sitemap.
Question 8: Bandwidth¶
65.4 MiB in total. sum starts at 0 and adds field 10 on every line; the END block converts bytes to MiB (1,048,576 bytes).
Bytes per path:
awk '{split($7, u, "?"); bytes[u[1]] += $10}
END {for (p in bytes) printf "%7.1f MiB %s\n", bytes[p] / 1048576, p}' access.log | sort -rn | head -n 5
| Part | Meaning |
|---|---|
split($7, u, "?") |
Split the path at ? into array u; u[1] is the part before it |
bytes[u[1]] += $10 |
Add this response's size to that path's total |
printf "%7.1f MiB %s\n" |
Right-align the size in 7 characters with one decimal |
sort -rn |
The numbers come first on each line, so a numeric sort ranks them |
/static/js/app.js alone is about 62% of all traffic (40.9 of 65.4 MiB). It is a 150 KB JavaScript bundle downloaded on hundreds of page views. Caching it in browsers longer, or compressing it, would cut the shop's bandwidth by more than half. Note the 304 responses in question 4: those are browsers that already had a cached copy, which cost 0 bytes.
Question 9: Who is visiting¶
961 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
952 Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0
864 Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1
790 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15
779 Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36
283 Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
264 python-requests/2.31.0
107 Mozilla/5.0 zgrab/0.x
There are only 8 distinct user agents, so head shows them all.
| Stage | What it does |
|---|---|
awk -F'"' |
Split each line at double quotes. Field 2 is the request, 4 the referer, 6 the user agent. |
{print $6} |
The user agent, spaces and all |
sort | uniq -c | sort -rn |
Frequency count |
Splitting on whitespace would have failed: the user agents contain different numbers of spaces, so they span a varying number of fields.
The bot share:
awk -F'"' '$6 ~ /bot|python-requests|zgrab/ {b++} END {printf "%d of %d (%.1f%%)\n", b, NR, 100 * b / NR}' access.log
13.1% of requests (654) came from bots and scripts. Testing the user-agent field ($6 ~) rather than the whole line ensures that a path or referer containing "bot" cannot be miscounted. grep -c -E 'bot|python-requests|zgrab' access.log also gives 654 here.
Question 10: What customers want¶
| Stage | Sample output | What it does |
|---|---|---|
grep -o 'q=[^ ]*' |
q=webcamq=laptop |
Print only the matched part: q= followed by non-space characters |
cut -d= -f2 |
webcam |
The value after = |
tr '+' ' ' |
usb-c cable |
In URLs, + encodes a space |
sort | uniq -c | sort -rn |
(above) | Frequency count |
Monitors and keyboards are the most searched for, closely followed by USB-C cables. A real query string can contain several parameters (?q=laptop&page=2); the pattern q=[^ &]* would stop at the &.
The total, 279 searches, matches the /search count in question 3, which is a good cross-check.
Stretch: an hourly report in one awk program¶
awk '{h = substr($4, 14, 2); n[h]++; if ($9 >= 500) e[h]++}
END {for (h in n) printf "%s:00 %4d requests %3d server errors %5.1f%%\n", h, n[h], e[h], 100 * e[h] / n[h]}' access.log | sort
00:00 59 requests 2 server errors 3.4%
01:00 35 requests 0 server errors 0.0%
...
11:00 380 requests 1 server errors 0.3%
12:00 350 requests 0 server errors 0.0%
13:00 308 requests 1 server errors 0.3%
14:00 321 requests 27 server errors 8.4%
15:00 321 requests 2 server errors 0.6%
16:00 284 requests 2 server errors 0.7%
...
How it works:
- For every line,
hholds the hour,n[h]counts all requests in that hour, ande[h]counts only server errors. - In
END, the loop visits every hour that had requests. For hours with no errors,e[h]was never set; awk treats an unset value as 0 in arithmetic and prints it as0with%3d. for (h in n)has no defined order, sosortputs the hours in order. Because every line starts with a zero-padded hour, a plain text sort is correct.
The error rate confirms what the counts suggested: 8.4% of requests failed with server errors during the 14:00 hour, against well under 1% at other busy times. Rates matter more than counts when traffic varies: 3 errors at 00:00 is a higher rate than 3 errors at 17:00.
Bonus: real logs¶
Your output will differ, because these logs describe your own machine. These pipelines show the technique; adapt them freely. Mint 22 writes syslog and auth.log lines like this:
Field 1 is the timestamp, field 2 the hostname, and field 3 the program, usually with [pid] and a trailing colon.
Which programs write the most to syslog?
awk '{print $3}' /var/log/syslog | sed 's/\[[0-9]*\]//; s/:$//' | sort | uniq -c | sort -rn | head -n 5
sed makes two substitutions: remove [digits], then remove a colon at the end, so systemd[1]: and systemd: both become systemd.
Messages per hour, and error messages per hour:
cut -c1-13 /var/log/syslog | uniq -c | tail -n 3
grep -i 'error' /var/log/syslog | cut -c1-13 | sort | uniq -c | sort -rn | head -n 3
2884 2026-09-21T09
542 2026-09-21T10
186 2026-09-21T11
55 2026-09-20T10
52 2026-09-20T13
45 2026-09-21T05
The first 13 characters of an ISO timestamp are the date and hour, so cut -c1-13 is all the extraction you need. A burst right after boot is normal.
Most frequent sudo commands:
sed 's/.*COMMAND=//' deletes everything up to and including COMMAND=. Because .* is greedy, it removes as much as possible, which is exactly what you want here.
Package activity:
awk '{print $3}' /var/log/dpkg.log | sort | uniq -c | sort -rn
awk '$3 == "upgrade" {print $4}' /var/log/dpkg.log | cut -d: -f1 | sort | uniq -c | sort -rn | head -n 5
dpkg.log lines look like 2026-09-21 10:19:59 upgrade firefox:amd64 1.0 1.1. Field 3 is the action and field 4 the package with its architecture, which cut -d: -f1 strips. Older logs are rotated to dpkg.log.1 and compressed as dpkg.log.2.gz; zcat /var/log/dpkg.log.*.gz | awk ... reads the compressed ones, and Archives and compression explains the tools.
What you practised¶
- The frequency-count idiom,
extract | sort | uniq -c | sort -rn | head, answered six of the ten questions. - awk associative arrays for sums and multi-column aggregates, with
ENDandprintffor reports. - Testing fields (
$9 >= 500,$6 ~ /bot/) instead of text anywhere in the line, and extracting values by position (substr) when a regex could match the wrong place. - Splitting on a different delimiter (
-F'"',cut -d:,cut -d'?') to reach awkward fields. - Cross-checking answers against each other (search count versus
/searchhits, scanner hours versus the night-time bump).
Next¶
When you can solve these without notes, you are ready for Level 2: Shell scripting, where you will turn pipelines like these into reusable, robust scripts.