Language Atlas (mini-courses for more languages) Module 11 – Nim for Python and C programmers
Project: a log analyser in Nim
Build loglens in Nim: stream an access log, apply a written line rule, compute nearest-rank p95 per endpoint, and test it natively and on Node.js.
What you will learn
- Build loglens as modules that stream a log one line at a time and total requests, errors and latencies per endpoint
- Write the line rule with strscans and matchers of your own, and reject what parseInt would have accepted
- Compute the nearest-rank p95, and explain why other percentile methods give other numbers
- Test the tool with std/unittest and with black-box cases on the native build and the Node.js build
- Measure a release build against a debug build on a million lines
Before you start
What you’ll learn · Before you start
What you will learn
- Build loglens as modules that stream a log one line at a time and total requests, errors and latencies per endpoint
- Write the line rule with strscans and matchers of your own, and reject what parseInt would have accepted
- Compute the nearest-rank p95, and explain why other percentile methods give other numbers
- Test the tool with std/unittest and with black-box cases on the native build and the Node.js build
- Measure a release build against a debug build on a million lines
Before you start
On this page
loglens reads an access log and prints, for every endpoint, its number of requests, its p95 and largest latency and
its count of 5xx errors. Built with -d:release, it read a million lines in 0.43 s of processor time, under an eighth
of the 3.65 s that the debug build needed; built with nim js, the same core printed the same reports, byte for byte,
on Node.js.
This project builds the tool in modules with a written specification, replaces a first draft of the line rule that
accepted +200 and 2_00 and crashed on a twenty-digit latency, works out p95 by nearest rank and compares it with
two other definitions, tests it with std/unittest and five black-box cases on both backends, and times the two
builds. The practice builds the same report again in Python, step by step.
The specification fixes every line of the report
Here is the sample log of this project: requests to four endpoints, with the two kinds of line that real logs contain and a tool must not count, a method in lower case and a line without its latency:
GET /api/orders 200 35ms
GET /api/orders 200 42ms
POST /api/orders 201 120ms
GET /health 200 1ms
GET /api/orders 500 230ms
GET /api/products 200 18ms
get /api/orders 200 30ms
GET /api/orders 200 38ms
GET /api/products 200 25ms
GET /api/products 503 12ms
GET /health 200 2ms
GET /api/orders 200
POST /api/orders 201 95ms
GET /api/orders 200 40ms Before any Nim was written, the report was pinned down in seven rules, precise enough that a Python program and a Nim program built from them must print identical bytes:
- A line is valid when it has four fields separated by single spaces and nothing after them: a method of one or more
capital letters A to Z, a path that starts with
/, a status of exactly three digits from 100 to 599, which is RFC 9110’s form and range of an HTTP status code, and a latency of one to nine digits followed directly byms. - Every other line, an empty one too, is malformed: counted, and otherwise left out.
- The endpoint of a valid line is its method, a space and its path, as
GET /api/orders. - For each endpoint the report prints its count of requests, its p95 and its largest latency, and how many of its statuses are from 500 to 599, the class RFC 9110 calls server errors.
- The endpoints with the most requests come first; equal counts are ordered by the endpoint’s name, byte by byte.
- A last line gives the lines read, the endpoints and the malformed lines.
- A missing argument prints a usage line on standard error: The second output stream every program has, meant for error messages. Kept apart from standard output, it lets a report be saved to a file while its errors still reach the terminal or a log. More in “Project: a marks report in Object Pascal” with exit status: The whole number a program returns to whatever started it: 0 for success by convention, anything else for a failure. Shells and build pipelines read it to decide what happens next. More in “Project: a marks report in Object Pascal” 2, and a file that cannot be read a message with exit status 1.
Step through what loglens does with the lines of sample.log:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- One line at a time. loglens reads sample.log with file.lines, which hands over one line at a time and drops its line ending, \n or \r\n. The whole file is never held in memory.
- The rule. parseLine accepts GET /api/orders 200 35ms: an upper-case method, a path that starts with /, a status of three digits and a latency of digits followed by ms.
- A malformed line. get /api/orders 200 30ms has its method in lower case, and GET /api/orders 200 has no latency: each is counted as malformed, and loglens moves on to the next line.
- Into the table. A valid line goes to its endpoint's record in a Table: one more request, one more 5xx when the status is 500 to 599, and its latency added to a seq.
- Until the file ends. The loop goes back for the next line until the file ends: 14 lines, 12 of them valid, in 4 endpoints.
- The order of the report. The endpoints are sorted as pairs of minus the count and the name: GET /api/orders with 5 requests first, and GET /health before POST /api/orders, which both have 2.
- p95 and max. Each endpoint's latencies are sorted, and p95 is the value at rank ceil(95 % of n): for GET /api/orders, 5 latencies give rank 5, so p95 is 230 ms, the largest.
- The report. loglens prints one line per endpoint and then lines=14 endpoints=4 malformed=2, exactly as the specification asks.
The report it printed, five lines from fourteen, is in the session of the next section. GET /api/orders had five
valid requests and one 5xx; GET /health and POST /api/orders both had two, so the rule of names put GET first.
Checkpoint
Check: The specification of the report
Each module has one job, and main only reads the file
loglens is four files of Nim and a test file. parse.nim knows the line rule and nothing else; stats.nim keeps the
totals and builds the report; loglens.nim is the program, and the only file that knows about arguments and files.
Step through the files and what each one calls:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The rule. parse.nim holds the line rule: the Request type and parseLine, which knows nothing about files or reports.
- The numbers. stats.nim imports parse.nim and keeps a Summary: the count of lines and malformed lines, and a Table of endpoints. It also holds nearestRank and report.
- The native program. loglens.nim is the only file that knows about the command line and files: it checks the arguments, opens the file and feeds stats.nim one line at a time.
- The same core on Node.js. loglens_node.nim imports the same stats.nim; it reads the file with Node's fs through importjs, because the JavaScript backend has no file access of its own.
- Unit tests. test_loglens.nim tests parseLine, nearestRank and report directly with std/unittest, without any file: seven tests in three suites.
- Black-box cases. The five cases run both programs as a user would, a log file in and a report out, and compare the report byte for byte with the expected .out file.
## loglens/stats: counts, errors and latencies per endpoint, and the report.
import std/[algorithm, tables]
import parse
type
Endpoint = object
count, errors: int
latencies: seq[int]
Summary* = object
lines*, malformed*: int
endpoints: Table[string, Endpoint]
proc nearestRank*(sorted: seq[int]; percent: int): int =
## The value at rank ceil(percent / 100 * n) of an ascending seq.
let rank = (percent * sorted.len + 99) div 100
sorted[max(rank, 1) - 1]
proc record(e: var Endpoint; req: Request) =
inc e.count
if req.status >= 500:
inc e.errors
e.latencies.add req.ms
proc add*(s: var Summary; line: string) =
inc s.lines
var req: Request
if parseLine(line, req):
record(s.endpoints.mgetOrPut(req.endpoint, Endpoint()), req)
else:
inc s.malformed
proc report*(s: Summary): seq[string] =
## One line per endpoint, the busiest first and equal counts in byte
## order of the endpoint; then a line of totals.
var order: seq[(int, string)]
for name, e in s.endpoints:
order.add (-e.count, name)
order.sort()
for (negCount, name) in order:
var lat = s.endpoints[name].latencies
lat.sort()
result.add name & " count=" & $(-negCount) & " p95=" &
$nearestRank(lat, 95) & "ms max=" & $lat[^1] & "ms 5xx=" &
$s.endpoints[name].errors
result.add "lines=" & $s.lines & " endpoints=" & $s.endpoints.len &
" malformed=" & $s.malformed ## loglens: a summary of an access log, one line per endpoint.
import std/os
import loglens/stats
proc main(): int =
if paramCount() != 1:
stderr.writeLine "usage: loglens <file>"
return 2
var file: File
if not open(file, paramStr(1)):
stderr.writeLine "loglens: cannot read ", paramStr(1)
return 1
defer: close(file)
var summary: Summary
for line in file.lines: # one line at a time
summary.add line
for line in summary.report:
echo line
quit main() Three choices in these files carry the lessons of this mini-course. for line in file.lines is a
streaming (a file): Reading input one piece at a time, such as one line of a log, and handling each piece before the next is read, so the whole file never has to fit in memory at once.: the file is read one line at a time and never held in memory as a whole,
and lines drops each line ending, so a log with Windows line endings reads the same. record takes the endpoint as
var Endpoint and gets it straight from the table with mgetOrPut, so the counts change in the table and not in a
copy, the trap of objects that the Python lesson showed. And report sorts a seq of pairs, minus the count and the
name, because tuples compare field by field: one sort call gives both orders of the specification.
from session import run
# loglens built and run on the sample log, then its unit tests, written with std/unittest.
run("""
cd loglens
nim c --hints:off -o:loglens src/loglens.nim
./loglens cases/sample.log
nim c -r --hints:off tests/test_loglens.nim
""") Output
$ cd loglens $ nim c --hints:off -o:loglens src/loglens.nim $ ./loglens cases/sample.log GET /api/orders count=5 p95=230ms max=230ms 5xx=1 GET /api/products count=3 p95=25ms max=25ms 5xx=1 GET /health count=2 p95=2ms max=2ms 5xx=0 POST /api/orders count=2 p95=120ms max=120ms 5xx=0 lines=14 endpoints=4 malformed=2 $ nim c -r --hints:off tests/test_loglens.nim [Suite] parseLine [OK] a valid line [OK] a sign, an underscore or a fourth digit is not a status [OK] spacing and what follows the latency [OK] twenty digits are a malformed line, not a crash [Suite] nearestRank [OK] p95 of 20 values is the 19th [OK] p95 of 19 values or fewer is the largest [Suite] report [OK] equal counts are in byte order of the endpoint
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 project_session.py
The report matches the specification line by line, and the seven unit tests of test_loglens.nim passed: three
suites, one each for parseLine, nearestRank and report, written with std/unittest, whose check reports the
failing expression when a test fails.
Checkpoint
Check: Modules, streaming and the table
A written rule decides what a line is
The first draft of the rule read both numbers with $i, the integer matcher of scanf, and ended with $., which
the strscans documentation says to append when nothing may follow the pattern: without it, scanf only checks that
the line starts with the pattern. The draft looked strict. Here it is on six lines:
# scanf_i.nim: the first draft of the line rule, with $i for both numbers.
import std/strscans
for line in ["GET /a 200 35ms", "GET /a +200 35ms", "GET /a 0200 35ms",
"GET /a 2_00 35ms", "GET /a 200 -5ms",
"GET /a 200 99999999999999999999ms"]:
var meth, path: string
var status, ms: int
try:
let ok = scanf(line, "$w $+ $i $ims$.", meth, path, status, ms)
echo line, " -> ", ok, ", status ", status, ", ms ", ms
except ValueError as e:
echo line, " -> ValueError: ", e.msg from session import run
# scanf_i.nim: the first draft of loglens's line rule, which read both numbers with $i, on six lines.
run("""
cd rules
nim c -r --hints:off scanf_i.nim
""") Output
$ cd rules $ nim c -r --hints:off scanf_i.nim GET /a 200 35ms -> true, status 200, ms 35 GET /a +200 35ms -> true, status 200, ms 35 GET /a 0200 35ms -> true, status 200, ms 35 GET /a 2_00 35ms -> true, status 200, ms 35 GET /a 200 -5ms -> true, status 200, ms -5 GET /a 200 99999999999999999999ms -> ValueError: Parsed integer outside of valid range
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rules_session.py
$i reads with parseutils.parseInt, which has its own idea of an integer: it took +200, 0200 and 2_00 as 200
and a latency of -5, and on twenty digits it raised a ValueError, which in a tool that reads a stranger’s log is a
crash on one bad line. None of those is what the specification says. So parse.nim matches the numbers with a matcher
of its own, as the strscans documentation shows with its ndigits example: digits(3, 3) for the status and
digits(1, 9) for the latency, only ASCII digits, and nine digits at most, which keeps every latency inside the 32
bits of an int on the JavaScript backend:
## loglens/parse: the line rule of the specification.
import std/[strscans, strutils]
type Request* = object
endpoint*: string ## the method and the path, as "GET /api/orders"
status*: int
ms*: int
proc digits(input: string; value: var int; start: int;
least, most: int): int =
## A scanf matcher: from least to most ASCII digits, no sign, no "_".
var i = start
var n = 0
while i < input.len and input[i] in Digits:
if i - start == most:
return 0 # more digits than allowed
n = n * 10 + (ord(input[i]) - ord('0'))
inc i
if i - start < least:
return 0
value = n
i - start
proc parseLine*(line: string; req: var Request): bool =
## True for METHOD PATH STATUS LATENCYms: four fields, single spaces,
## nothing after them. Then req holds the request.
var meth, path: string
var status, ms: int
if not scanf(line, "$w $+ ${digits(3, 3)} ${digits(1, 9)}ms$.",
meth, path, status, ms):
return false
if not meth.allCharsInSet({'A'..'Z'}) or not path.startsWith('/'):
return false
if status notin 100..599:
return false
req = Request(endpoint: meth & " " & path, status: status, ms: ms)
true edge.log, one of the black-box cases, holds the five lines that broke the draft, seven more that the rule must
refuse and three valid ones. The final rule found 12 of its 15 lines malformed, and none of them stopped the program:
GET /api/orders 200 35ms
GET /api/orders 200 35ms
GET /api/orders 200 35ms
GET /api/orders 200 35 ms
GET /api/orders 2_00 35ms
GET /api/orders +200 35ms
GET /api/orders 0200 35ms
GET /api/orders 600 35ms
GET /api/orders 200 -5ms
GET /api/orders 200 99999999999999999999ms
Get /api/orders 200 35ms
GET api/orders 200 35ms
DELETE /api/orders/7 204 0ms
GET /api/orders 599 1000000ms Letting a parser be the specification
scanf with $i looks like a strict pattern, but parseInt behind it accepts a sign, leading zeros and
underscores, and raises on a number too large for an int. Python’s int() is more generous still: its
documentation allows signs, leading zeros, surrounding whitespace, underscores and the decimal digits of every
script. Write the rule down, code it with matchers that check every character, and test it on lines like these.
Checkpoint
Check: A line rule written as matchers
p95 by nearest rank, and why dashboards disagree
p95 is the latency that 95 % of requests finished within. loglens computes it by the
nearest-rank percentile: The p-th percentile taken as the value at rank ceil(p / 100 × n) of the n sorted values, counting from 1. It is always one of the values; for p95 and 19 values or fewer it is the largest.: sort the latencies, take the rank 95 % of their count
rounded up, (95 * n + 99) div 100 in whole numbers, and report the value at that rank. It is always one of the
latencies that happened, never a value between two. The same 20 latencies, by three definitions:
# percentiles.py: one p95 by three definitions, on the same 20 latencies.
import math
import statistics
ms = [12, 15, 18, 20, 22, 25, 25, 28, 30, 33, 35, 38, 40, 42, 45, 50, 60, 80, 120, 230]
ordered = sorted(ms)
rank = math.ceil(0.95 * len(ordered))
print(f"nearest rank, as loglens: {ordered[rank - 1]} (rank {rank} of {len(ordered)})")
print("statistics.quantiles, exclusive:", statistics.quantiles(ms, n=100)[94])
print("statistics.quantiles, inclusive:", statistics.quantiles(ms, n=100, method="inclusive")[94]) Output
nearest rank, as loglens: 120 (rank 19 of 20) statistics.quantiles, exclusive: 224.5 statistics.quantiles, inclusive: 125.5
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 percentiles.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
| Method | p95 of the 20 latencies | The same rule elsewhere |
|---|---|---|
| Nearest rank | 120 ms, the 19th value | R’s type 1, the inverse of the empirical distribution function |
statistics.quantiles, exclusive, Python’s default |
224.5 ms, between the 19th and 20th | positions i / (m + 1), R’s type 6, which R’s documentation says Minitab and SPSS use |
statistics.quantiles, inclusive |
125.5 ms, between the 19th and 20th | positions (i - 1) / (m - 1), R’s type 7, R’s default |
R’s documentation describes nine such algorithms, the ones discussed by Hyndman and Fan in their paper on sample
quantiles; none is wrong, but a report must say which one it uses. The two of Python interpolate between the two
nearest values, so they can report a latency that no request had. Nearest rank has a consequence that surprises
people: with 19 values or fewer, the rank works out to the last value, so the p95 of a small endpoint is simply its
maximum. That is why every line of sample.log’s report had p95 equal to max, and why the million-line run below
does not.
Key idea
A percentile has several definitions: say which one a report uses. Nearest rank always reports a latency that happened, and for 19 values or fewer its p95 is the largest of them.
Checkpoint
Check: p95 by nearest rank
Unit tests check the parts, black-box cases check both programs
The unit tests looked inside: they called parseLine, nearestRank and report directly. A
black-box test: A test that runs the whole program as a user would, with an input file, and compares only what comes out, standard output, standard error and the exit status, with expected files, without looking at the code inside. More in “Project: a marks report in Object Pascal” runs the program as a user would, a log in and a report out, and
compares the report byte for byte with an expected file. loglens has five such cases, and two programs to run them
on: the native build, and loglens_node.nim, which imports the same stats.nim and reads the file with Node.js’s
fs, because the backend guide lists file management among what the JavaScript target does not have:
## loglens for Node.js: the same core, with the file read by Node's fs.
import std/[os, strutils]
import loglens/stats
proc exists(path: cstring): bool {.importjs: "require('fs').existsSync(#)".}
proc readText(path: cstring): cstring {.importjs: "require('fs').readFileSync(#, 'utf8')".}
proc consoleError(text: cstring) {.importjs: "console.error(#)".}
proc main(): int =
if paramCount() != 1:
consoleError("usage: loglens <file>")
return 2
if not exists(cstring(paramStr(1))):
consoleError(cstring("loglens: cannot read " & paramStr(1)))
return 1
var pieces = splitLines($readText(cstring(paramStr(1))))
if pieces.len > 0 and pieces[^1].len == 0:
pieces.setLen(pieces.len - 1) # the newline that ends the file
var summary: Summary
for line in pieces:
summary.add line
for line in summary.report:
echo line
quit main() from session import run
# The native build and the Node.js build of loglens, each run on the five cases and compared byte for byte with the
# expected reports; then the two refusals of the specification.
run("""
cd loglens
nim c --hints:off -o:loglens src/loglens.nim
nim js -d:nodejs --hints:off -o:loglens_node.js src/loglens_node.nim
for f in cases/*.log; do ./loglens "$f" | cmp -s - "${f%.log}.out" && echo "native pass $f" || echo "native FAIL $f"; done
for f in cases/*.log; do node loglens_node.js "$f" | cmp -s - "${f%.log}.out" && echo "node pass $f" || echo "node FAIL $f"; done
!./loglens
!./loglens cases/missing.log
""") Output
$ cd loglens
$ nim c --hints:off -o:loglens src/loglens.nim
$ nim js -d:nodejs --hints:off -o:loglens_node.js src/loglens_node.nim
$ for f in cases/*.log; do ./loglens "$f" | cmp -s - "${f%.log}.out" && echo "native pass $f" || echo "native FAIL $f"; done
native pass cases/crlf.log
native pass cases/edge.log
native pass cases/empty.log
native pass cases/sample.log
native pass cases/ties.log
$ for f in cases/*.log; do node loglens_node.js "$f" | cmp -s - "${f%.log}.out" && echo "node pass $f" || echo "node FAIL $f"; done
node pass cases/crlf.log
node pass cases/edge.log
node pass cases/empty.log
node pass cases/sample.log
node pass cases/ties.log
$ ./loglens
usage: loglens <file>
[exit status 2]
$ ./loglens cases/missing.log
loglens: cannot read cases/missing.log
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blackbox_session.py
All ten runs passed, so the native build and the JavaScript build agree on every case: the sample, the edges, an empty
file, a file of ties and a file with Windows line endings, which file.lines and splitLines both read as the
specification means. The last two commands kept the promise of rule 7: no file name gave the usage line and exit
status 2, a missing file a message and exit status 1, so a script that runs loglens can tell the two apart.
Keep the expected files with the code
Each case is a pair of files, cases/edge.log and cases/edge.out. A new rule or a bug report becomes a new pair,
and the loop in the session tests every build against all of them in a second.
Checkpoint
Check: Unit tests and black-box cases
A million lines: the release build and what it costs
The specification asks loglens to read large logs, and the build mode decides how fast. The session writes a million
lines with gen.py, a generator with a fixed seed, so the lines are the same on every run, builds loglens twice and
times both builds with /usr/bin/time -p:
"""gen.py: writes n lines in loglens's format to standard output, the same lines on every run (seed 7)."""
import random
import sys
# Endpoints with their share of the traffic and a typical latency in milliseconds.
ENDPOINTS = [
("GET /api/orders", 40, 35),
("GET /api/products", 30, 20),
("POST /api/orders", 15, 110),
("GET /health", 10, 2),
("DELETE /api/orders", 5, 60),
]
def main():
n = int(sys.argv[1])
rnd = random.Random(7)
names = [e[0] for e in ENDPOINTS]
weights = [e[1] for e in ENDPOINTS]
typical = {e[0]: e[2] for e in ENDPOINTS}
out = []
for i in range(n):
name = rnd.choices(names, weights)[0]
ms = int(typical[name] * rnd.lognormvariate(0, 0.6))
status = 503 if rnd.random() < 0.01 else 200
if i % 1000 == 999:
out.append(f"{name} {status} {ms}") # a malformed line: no "ms"
else:
out.append(f"{name} {status} {ms}ms")
sys.stdout.write("\n".join(out) + "\n")
main() from session import run
# A million lines from gen.py, then a debug and a release build of loglens, each timed with /usr/bin/time -p (real is
# the time on the clock, user and sys the processor time), their reports compared, and the size of each program.
run("""
cd speed
python3 gen.py 1000000 > big.log
wc -l < big.log
cd ../loglens
nim c --hints:off -o:loglens_debug src/loglens.nim
nim c --hints:off -d:release -o:loglens_release src/loglens.nim
/usr/bin/time -p ./loglens_debug ../speed/big.log > debug.txt
/usr/bin/time -p ./loglens_release ../speed/big.log > release.txt
cmp debug.txt release.txt
cat release.txt
wc -c < loglens_debug
wc -c < loglens_release
""") Output
$ cd speed $ python3 gen.py 1000000 > big.log $ wc -l < big.log 1000000 $ cd ../loglens $ nim c --hints:off -o:loglens_debug src/loglens.nim $ nim c --hints:off -d:release -o:loglens_release src/loglens.nim $ /usr/bin/time -p ./loglens_debug ../speed/big.log > debug.txt real 17.21 user 3.52 sys 0.13 $ /usr/bin/time -p ./loglens_release ../speed/big.log > release.txt real 5.02 user 0.40 sys 0.03 $ cmp debug.txt release.txt $ cat release.txt GET /api/orders count=398963 p95=94ms max=491ms 5xx=3933 GET /api/products count=300174 p95=53ms max=296ms 5xx=3137 POST /api/orders count=149428 p95=295ms max=1387ms 5xx=1506 GET /health count=100391 p95=5ms max=30ms 5xx=946 DELETE /api/orders count=50044 p95=160ms max=749ms 5xx=509 lines=1000000 endpoints=5 malformed=1000 $ wc -c < loglens_debug 211600 $ wc -c < loglens_release 116768
This output changes from run to run: the times depend on the computer and on what else it is doing, so they change from run to run, and the sizes of the programs depend on the folder they were built in; the reports do not
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 speed_session.py
| Build | user + sys, processor time | real, on the clock | Size |
|---|---|---|---|
Debug, nim c |
3.65 s | 17.21 s | 211,600 bytes |
Release, nim c -d:release |
0.43 s | 5.02 s | 116,768 bytes |
cmp printed nothing, so the two reports were identical, down to the byte: the 1,000 malformed lines that gen.py
wrote on purpose, one in every thousand, and the five endpoints. With 50,044 requests or more each, p95 now sits
well below the largest latency: 94 ms against 491 ms for GET /api/orders. The release build used under an eighth of
the debug build’s processor time: The time a processor spent running a program: user, in the program's own code, and sys, in the system's code on its behalf, as /usr/bin/time -p reports them. Unlike real, the time on the clock, it hardly grows when other programs keep the machine busy. More in “Project: a CSV statistics tool in D”, because -d:release turns on the C compiler’s
optimiser and turns stack traces off while it keeps every run-time check, as the first lesson of this mini-course
showed. The time on the clock was longer than the processor time for both, because the machine was busy with other
work, which is why a budget is better stated in processor time.
Checkpoint
Check: Release against debug on a million lines
Practise: loglens again, in Python
Rebuilding loglens in Python from the same seven rules gives the Nim tool a test oracle: A second, trusted way to work out the expected results of tests, such as a short program written straight from the specification, used to check an implementation on many inputs.: run both on one log, and any line where they differ points at a bug in one of them or a gap in the rules. The lab does it in three steps, the line rule, the p95 and the report, and its sample tests are lines and reports that the Nim tool printed in this lesson’s sessions:
Code lab · 3 steps
Build loglens again, in Python
Write the line rule, the nearest-rank p95 and the whole report, so that the Python version prints exactly what loglens printed for sample.log, ties.log, edge.log and empty.log.
- Step 1: the line rule of loglens To do
- Step 2: the nearest-rank p95 To do
- Step 3: the whole loglens report To do
Exercise · Easy · Python
Step 1: the line rule of loglens
Write parse_line(line) in rule.py. It returns (endpoint, status, ms) for a line that loglens counts, with the endpoint as the method, one space and the path, and the two numbers as ints, and None for a malformed line. The rule of the specification, as parse.nim codes it:
- Four fields separated by single spaces, with nothing before the first or after the last.
- The method: one or more of the upper-case letters
AtoZ. - The path: it starts with
/. - The status: exactly three of the digits
0to9, with a value from 100 to 599. - The latency: one to nine of the digits
0to9, followed directly byms.
Python's int() is more generous than the rule: it accepts "+200", "2_00" and " 200", and str.isdigit() is true for digits of other scripts, so check the characters before you convert. The starter splits the line and trusts int(); the sample tests are lines of sample.log and edge.log.
Starter code · rule.py
def parse_line(line):
"""(endpoint, status, ms) for a line that loglens counts, or None for a malformed one."""
# Replace this with your code: it trusts int() and checks nothing else.
parts = line.split()
if len(parts) != 4:
return None
method, path, status, latency = parts
try:
return (method + " " + path, int(status), int(latency.removesuffix("ms")))
except ValueError:
return None The sample tests · test_rule.py
from rule import parse_line
def test_lines_of_the_sample():
"""the first line of sample.log, and the one with a 5xx status"""
assert parse_line("GET /api/orders 200 35ms") == ("GET /api/orders", 200, 35)
assert parse_line("GET /api/products 503 12ms") == ("GET /api/products", 503, 12)
def test_the_two_malformed_lines_of_the_sample():
"""a method in lower case, and a line without its latency"""
assert parse_line("get /api/orders 200 30ms") is None
assert parse_line("GET /api/orders 200") is None
def test_spacing():
"""edge.log: a trailing space, a double space and a space before ms"""
assert parse_line("GET /api/orders 200 35ms ") is None
assert parse_line("GET /api/orders 200 35ms") is None
assert parse_line("GET /api/orders 200 35 ms") is None
def test_numbers_that_int_would_take():
"""edge.log: an underscore, a sign, four digits and a negative latency"""
assert parse_line("GET /api/orders 2_00 35ms") is None
assert parse_line("GET /api/orders +200 35ms") is None
assert parse_line("GET /api/orders 0200 35ms") is None
assert parse_line("GET /api/orders 200 -5ms") is None
def test_ranges_and_lengths():
"""edge.log: a status of 600, twenty digits of latency, and the largest valid status"""
assert parse_line("GET /api/orders 600 35ms") is None
assert parse_line("GET /api/orders 200 99999999999999999999ms") is None
assert parse_line("GET /api/orders 599 1000000ms") == ("GET /api/orders", 599, 1000000)
def test_method_and_path():
"""edge.log: a method not in capitals, a path without its slash, and a latency of 0"""
assert parse_line("Get /api/orders 200 35ms") is None
assert parse_line("GET api/orders 200 35ms") is None
assert parse_line("DELETE /api/orders/7 204 0ms") == ("DELETE /api/orders/7", 204, 0)
def test_an_empty_line():
"""the empty line of edge.log"""
assert parse_line("") is None A hint
Split the line with line.split(" "): a double space or a trailing space gives an empty field, so anything but four non-empty fields is malformed. Then test each field against its rule with the set of characters it may hold, such as set(status) <= set("0123456789"), and only then call int().
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · Python
Step 2: the nearest-rank p95
Write p95(values) in p95.py: the 95th percentile of a list of latencies, by the nearest-rank method that nearestRank in stats.nim uses. values holds at least one whole number, in any order.
Sort the values; the rank is 95 % of their count, rounded up to a whole number; the answer is the value at that rank, counting from 1. Work the rank out with whole numbers, (95 * n + 99) // 100, as stats.nim does. Then p95 of 19 values or fewer is always the largest of them, and of 20 values it is the 19th.
The starter interpolates, as Python's statistics.quantiles does, so its answers fall between two values; the sample tests are p95 values that loglens printed and the three results of percentiles.py.
Starter code · p95.py
import statistics
def p95(values):
"""The 95th percentile of values by the nearest-rank method."""
# Replace this with your code: it interpolates between two values, which loglens never does.
if len(values) == 1:
return values[0]
return statistics.quantiles(values, n=100)[94] The sample tests · test_p95.py
from p95 import p95
def test_the_twenty_latencies_of_percentiles_py():
"""nearest rank gave 120, the 19th of 20; quantiles gave 224.5 and 125.5"""
ms = [12, 15, 18, 20, 22, 25, 25, 28, 30, 33, 35, 38, 40, 42, 45, 50, 60, 80, 120, 230]
assert p95(ms) == 120
def test_five_values_give_the_largest():
"""GET /api/orders in sample.log: p95=230ms, its largest latency"""
assert p95([35, 42, 230, 38, 40]) == 230
def test_three_values_in_any_order():
"""GET /api/products in sample.log: p95=25ms"""
assert p95([18, 25, 12]) == 25
def test_one_value():
"""DELETE /api/orders/7 in edge.log: one request of 0 ms"""
assert p95([0]) == 0
def test_twenty_values_give_the_nineteenth():
"""the unit test of stats.nim: 1 to 20 gives 19"""
assert p95(list(range(1, 21))) == 19
def test_twenty_one_values():
"""rank (95 * 21 + 99) // 100 = 20, so the 20th of 21"""
assert p95(list(range(1, 22))) == 20 A hint
For 20 values the rank is (95 * 20 + 99) // 100 = 19, so the answer is sorted(values)[18]: subtract 1 from the rank to index a Python list.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
Step 3: the whole loglens report
The last step puts the first two together. Write report(lines) in report.py: it takes the lines of a log, as file.lines gives them, and returns the lines that loglens prints:
- One line per endpoint:
"<endpoint> count=<n> p95=<p>ms max=<m>ms 5xx=<e>", where<e>counts the statuses from 500 to 599. - The endpoints with the most requests first; endpoints with equal counts in the order of their names, compared character by character as Python compares
strs. - Then one line of totals:
"lines=<all lines> endpoints=<k> malformed=<lines parse_line refused>".
The starter holds first attempts of the two earlier steps and sorts by count only: copy your parse_line and p95 over them, then fix report. The sample tests are the expected files of the black-box cases, sample.out, ties.out, edge.out and empty.out.
Starter code · report.py
def parse_line(line):
"""Step 1: (endpoint, status, ms), or None for a malformed line."""
parts = line.split()
if len(parts) != 4:
return None
method, path, status, latency = parts
try:
return (method + " " + path, int(status), int(latency.removesuffix("ms")))
except ValueError:
return None
def p95(values):
"""Step 2: the nearest-rank 95th percentile."""
ordered = sorted(values)
return ordered[int(0.95 * len(ordered))]
def report(lines):
"""The lines loglens prints for the lines of a log."""
stats = {}
malformed = 0
for line in lines:
request = parse_line(line)
if request is None:
malformed += 1
continue
endpoint, status, ms = request
stats.setdefault(endpoint, []).append((status, ms))
out = []
for endpoint in sorted(stats, key=lambda e: -len(stats[e])):
rows = stats[endpoint]
latencies = [ms for _, ms in rows]
errors = sum(1 for status, _ in rows if status >= 500)
out.append(f"{endpoint} count={len(rows)} p95={p95(latencies)}ms max={max(latencies)}ms 5xx={errors}")
out.append(f"lines={len(lines)} endpoints={len(stats)} malformed={malformed}")
return out The sample tests · test_report.py
from report import report
def test_sample():
"""sample.log: fourteen lines, two of them malformed; GET /health and POST /api/orders tie at 2"""
lines = [
'GET /api/orders 200 35ms',
'GET /api/orders 200 42ms',
'POST /api/orders 201 120ms',
'GET /health 200 1ms',
'GET /api/orders 500 230ms',
'GET /api/products 200 18ms',
'get /api/orders 200 30ms',
'GET /api/orders 200 38ms',
'GET /api/products 200 25ms',
'GET /api/products 503 12ms',
'GET /health 200 2ms',
'GET /api/orders 200',
'POST /api/orders 201 95ms',
'GET /api/orders 200 40ms',
]
assert report(lines) == [
'GET /api/orders count=5 p95=230ms max=230ms 5xx=1',
'GET /api/products count=3 p95=25ms max=25ms 5xx=1',
'GET /health count=2 p95=2ms max=2ms 5xx=0',
'POST /api/orders count=2 p95=120ms max=120ms 5xx=0',
'lines=14 endpoints=4 malformed=2',
]
def test_ties():
"""ties.log: four endpoints of one request each, in byte order of their names"""
lines = [
'POST /b 200 5ms',
'GET /b 200 5ms',
'GET /a 200 7ms',
'POST /a 200 9ms',
]
assert report(lines) == [
'GET /a count=1 p95=7ms max=7ms 5xx=0',
'GET /b count=1 p95=5ms max=5ms 5xx=0',
'POST /a count=1 p95=9ms max=9ms 5xx=0',
'POST /b count=1 p95=5ms max=5ms 5xx=0',
'lines=4 endpoints=4 malformed=0',
]
def test_edge():
"""edge.log: twelve malformed lines, and a 599 with a latency of 1,000,000 ms"""
lines = [
'GET /api/orders 200 35ms',
'GET /api/orders 200 35ms ',
'GET /api/orders 200 35ms',
'GET /api/orders 200 35 ms',
'GET /api/orders 2_00 35ms',
'GET /api/orders +200 35ms',
'GET /api/orders 0200 35ms',
'GET /api/orders 600 35ms',
'GET /api/orders 200 -5ms',
'GET /api/orders 200 99999999999999999999ms',
'Get /api/orders 200 35ms',
'GET api/orders 200 35ms',
'',
'DELETE /api/orders/7 204 0ms',
'GET /api/orders 599 1000000ms',
]
assert report(lines) == [
'GET /api/orders count=2 p95=1000000ms max=1000000ms 5xx=1',
'DELETE /api/orders/7 count=1 p95=0ms max=0ms 5xx=0',
'lines=15 endpoints=2 malformed=12',
]
def test_empty():
"""empty.log: no lines at all"""
lines = []
assert report(lines) == [
'lines=0 endpoints=0 malformed=0',
] A hint
Sort a list of pairs (-count, endpoint): Python compares pairs item by item, so the largest count comes first and equal counts fall back to the endpoint's name, which is what stats.nim does with its seq of tuples.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then the self-check on the six ideas of the project. Each question comes when its idea is the one you have shown least, and each answer says why it is right or where it goes wrong:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up, for freshers and mid-level roles: what is the nearest-rank p95, and why does loglens use it?
It is the value at rank ⌈0.95 × n⌉ of the sorted latencies, counted from 1: for 20 latencies the 19th, for 21 the
20th, computed in whole numbers as (95 * n + 99) div 100. loglens uses it because the result is always a latency
that really happened, it needs no interpolation, and it is easy to state in a specification and to reproduce in
another language, which the Python version of the lab does. Its known effect is that for 19 values or fewer the p95
is the maximum, which is why every endpoint of the sample log showed p95 equal to max. Other tools interpolate: on the
lesson’s 20 latencies, Python’s statistics.quantiles gave 224.5 ms by default and 125.5 ms with
method="inclusive", where nearest rank gave 120 ms. A report should name its method.
Two longer questions, on a p95 that a dashboard contradicts and on a log too large for the tool’s memory:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
loglens reports a p95 of 120 ms for an endpoint, while the team's dashboard shows 224.5 ms for the same 20 requests, and an engineer says one of the two tools has a bug. How do you settle it, and what do you change so the question does not come back?
-
Mid to staff
loglens streams its input, yet on a log of two billion lines it runs out of memory. Explain where the memory goes, estimate it, and describe two ways to change the tool, with what each way gives up.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
When is a log line valid for loglens?
Four fields with single spaces and nothing after them: a method of capitals A to Z, a path starting with /, a status of three digits from 100 to 599, and 1 to 9 digits followed straight away by ms.
-
In what order does loglens list its endpoints, and how does one sort give it?
Most requests first, equal counts by name byte by byte. Sorting (minus count, name) pairs gives both orders, because tuples compare field by field.
-
Why does loglens read its input with for line in file.lines?
It streams the file one line at a time instead of holding it in memory, and it drops each line ending, so a log with Windows line endings reads the same.
-
Why does record fetch its endpoint with mgetOrPut?
mgetOrPut returns the table's own entry as var, so the counts change inside the table rather than in a copied object.
-
What did the first draft, with scanf's $i, accept that the specification forbids?
+200, 0200 and 2_00 as 200, and a latency of -5, and twenty digits raised a ValueError. The final rule uses digits(3, 3) and digits(1, 9), which check each character.
-
How is the nearest-rank p95 found, and why is it always a latency that happened?
Sort the latencies and take the one at rank ⌈0.95 × n⌉, which is (95 * n + 99) div 100 in whole numbers. It picks a recorded value, never one in between.
-
Why did every p95 in the report of sample.log equal its maximum?
For 19 values or fewer the nearest rank lands on the last one. In the million-line log, with 50,044 requests or more per endpoint, p95 fell well below the maximum: 94 ms against 491 ms for GET /api/orders.
-
What do loglens's five black-box cases compare, and on which programs?
A log in and a report out, byte for byte against an expected file, on the native build and on the Node.js build of the same core. All ten runs passed.
Key takeaways
- Seven rules, written before the code, pin down the line rule, the endpoint, the four numbers per endpoint, the order and the exit statuses, which is what lets a Python version print the very same report.
- loglens keeps the rule in
parse.nim, the totals instats.nimand the files inloglens.nim, reads withfile.linesone line at a time, and changes the table throughmgetOrPutinstead of a copy. scanfwith$iaccepted+200,0200and2_00and raised on twenty digits; matchers of its own make the rule exactly the specification’s.- p95 by nearest rank is a latency that happened; for 19 values or fewer it is the maximum, and other definitions, such as Python’s two, give other numbers.
- Seven unit tests check the parts, and five black-box cases passed on both the native build and the Node.js build.
- On a million lines the release build needed 0.43 s of processor time against 3.65 s for the debug build, and printed the same report.
Terms in this lesson
- nearest-rank percentile
- The p-th percentile taken as the value at rank ceil(p / 100 × n) of the n sorted values, counting from 1. It is always one of the values; for p95 and 19 values or fewer it is the largest.
- streaming (a file)
- Reading input one piece at a time, such as one line of a log, and handling each piece before the next is read, so the whole file never has to fit in memory at once.
References
- std/strscans (scanf, $., user-defined matchers) (Nim)
- std/strutils (Nim)
- std/tables (Table, mgetOrPut) (Nim)
- std/algorithm (sort) (Nim)
- std/syncio (open, lines) (Nim)
- std/os (paramCount, paramStr) (Nim)
- std/unittest (Nim)
- Nim Compiler User Guide: release and danger builds (Nim)
- Nim Backend Integration: the JavaScript target (Nim)
- Node.js File system: fs.readFileSync and fs.existsSync (OpenJS Foundation)
- RFC 9110 HTTP Semantics, section 15: status codes (IETF)
- Sample Quantiles in Statistical Packages (The American Statistician)
- R documentation: quantile, the nine sample quantile types (The R Foundation)
- statistics.quantiles (Python Software Foundation)
- Built-in Functions: int() (Python Software Foundation)
- time: the -p option (POSIX output format) (The Open Group)
- cmp: compare two files (The Open Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress