Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Nim 2.2.12, Clang 21.0.0 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

sample.log, the sample access log LOG · loglens/cases/sample.log
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:

  1. 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 by ms.
  2. Every other line, an empty one too, is malformed: counted, and otherwise left out.
  3. The endpoint of a valid line is its method, a space and its path, as GET /api/orders.
  4. 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.
  5. The endpoints with the most requests come first; equal counts are ordered by the endpoint’s name, byte by byte.
  6. A last line gives the lines read, the endpoints and the malformed lines.
  7. 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:

Interactive · stage by stage One line through loglens, then the report Step through what loglens does with the lines of sample.log, from the first line read to the last line printed.
One line through loglens, then the report, step 8 of 8. Showing Read the next line, Is the line valid?, Add it to its endpoint, Order the endpoints, Work out p95 and max, Print the report. In focus: Print the report.no: count it as malformedmore linesRead the next lineone line at a timeIs the line valid?parseLine and its ruleAdd it to its endpointcount, 5xx, the latencyOrder the endpointsbusiest first, then namesWork out p95 and maxnearest rank of 95 %Print the reportthen a line of totals

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 Which line of sample.log is malformed by the specification?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: get /api/orders 200 30ms

    sample.log has two malformed lines: the lower-case get, and GET /api/orders 200 without a latency. The report's last line counted them: malformed=2.

  2. Question 2 of 2 GET /health and POST /api/orders both had 2 requests. Why did GET /health come first in the report?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Equal counts are ordered by the endpoint's name, byte by byte, and G comes before P

    A tie needs a rule, or two implementations will print the same numbers in different orders. loglens sorts pairs of minus the count and the name.

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:

Interactive · step by step The loglens project, file by file Step through the files of the project and see which one calls which, and what checks each.
The loglens project, file by file, step 6 of 6. Showing src/loglens.nim, src/loglens_node.nim, loglens/stats.nim, loglens/parse.nim, tests/test_loglens.nim, cases/*.log and *.out. In focus: cases/*.log and *.out.importsimportsimportsteststestssrc/loglens.nimmain: arguments, the filesrc/loglens_node.nimmain for Node.jsloglens/stats.nimSummary, p95, the reportloglens/parse.nimRequest, parseLinetests/test_loglens.nimstd/unittestcases/*.log and *.outfive black-box cases

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The rule. parse.nim holds the line rule: the Request type and parseLine, which knows nothing about files or reports.
  2. 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.
  3. 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.
  4. 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.
  5. Unit tests. test_loglens.nim tests parseLine, nearestRank and report directly with std/unittest, without any file: seven tests in three suites.
  6. 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.nim: the totals and the report NIM · loglens/src/loglens/stats.nim
## 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.nim: arguments, the file, one line at a time NIM · loglens/src/loglens.nim
## 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.

loglens built and run on sample.log, then its unit tests Python · project_session.py
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

  1. Question 1 of 2 loglens reads with for line in file.lines. What does that give it, compared with reading the whole file first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One line at a time, so the file is never held in memory as a whole

    The totals still grow with the data, since every latency is kept for p95, but the file itself does not have to fit in memory.

  2. Question 2 of 2 record(s.endpoints.mgetOrPut(req.endpoint, Endpoint()), req) changes an endpoint's counts. What would go wrong with var e = s.endpoints.getOrDefault(req.endpoint) and then record(e, req)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: e is a copy of the endpoint, so the counts change in the copy and the table keeps the old ones

    With objects in a table, change them where they live: mgetOrPut, a var parameter or withValue.

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, with $i for both numbers NIM · rules/scanf_i.nim
# 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
The first draft on six lines Python · rules_session.py
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.nim: the rule, with a digits matcher NIM · loglens/src/loglens/parse.nim
## 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:

edge.log: lines at the edges of the rule LOG · loglens/cases/edge.log
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

  1. Question 1 of 2 Why does the line rule end its scanf pattern with $.?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Without it, scanf only checks that the line starts with the pattern, so trailing text would be accepted

    scanf matches a prefix by default; $. turns it into a match of the whole line.

  2. Question 2 of 2 The first draft read the status with $i. What did it make of GET /a 2_00 35ms?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A valid line with status 200: parseInt accepts underscores

    A parser's habits are not a specification. parse.nim's digits matcher accepts only ASCII digits, and nine of them at most.

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, three definitions Python · py/percentiles.py
# 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

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.

Descriptive Statistics Calculator Paste one endpoint's latencies and choose Hyndman–Fan type 1 for the nearest-rank p95 that loglens prints; type 7 gives R's default, the inclusive method of Python.

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

  1. Question 1 of 2 An endpoint has 5 latencies. Counting from 1 in the sorted latencies, which rank does the nearest-rank p95 take?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 5

    (95 × 5 + 99) div 100 = 574 div 100 = 5, so p95 is the 5th of 5, the largest. That is why GET /api/orders in sample.log printed p95=230ms and max=230ms.

  2. Question 2 of 2 On the same 20 latencies loglens says p95 is 120 ms and a dashboard says 224.5 ms. Which is wrong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Neither: they use different definitions, nearest rank and an interpolating one

    Before comparing two percentiles, find out which definition each uses. R's documentation describes nine.

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_node.nim: the same core, for Node.js NIM · loglens/src/loglens_node.nim
## 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()
Five cases on the native build and on Node.js, then the two refusals Python · blackbox_session.py
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

  1. Question 1 of 2 What does a black-box case of loglens compare?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The report printed for a log file with the expected .out file, byte for byte

    Unit tests check the parts; black-box cases check what a user gets. The same five cases checked both programs.

  2. Question 2 of 2 Why does loglens_node.nim read the log with Node.js's fs through importjs instead of file.lines?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The JavaScript target has no file management of its own, as the backend guide lists

    Keep the core free of files and command lines, and give each backend a small main of its own.

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: a million log lines, the same on every run Python · speed/gen.py
"""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()
A debug and a release build on a million lines Python · speed_session.py
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

  1. Question 1 of 2 On a million lines, how did the release build compare with the debug build in processor time?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It needed under an eighth of the processor time and printed the same report

    -d:release turns on the optimiser and turns stack traces off, and keeps every check, so it is the build to ship.

  2. Question 2 of 2 In the million-line report GET /api/orders had p95=94ms and max=491ms. Why does p95 differ from max here, when it equalled max in sample.log?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: With 398,963 requests the rank 95 % falls far below the last value; with 19 or fewer it is the last value

    p95 is informative only for endpoints with enough requests; below 20 it is the maximum by definition.

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.

  1. Step 1: the line rule of loglens To do
  2. Step 2: the nearest-rank p95 To do
  3. 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 A to Z.
  • The path: it starts with /.
  • The status: exactly three of the digits 0 to 9, with a value from 100 to 599.
  • The latency: one to nine of the digits 0 to 9, followed directly by ms.

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().

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.

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.

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.

  1. Question 1 of 12 What does loglens do when it is started without a file name?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: It prints a usage line on standard error and ends with exit status 2

    Two different statuses let a script tell a wrong call from a missing file.

  2. Question 2 of 12 Which statuses count in the 5xx column?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: From 500 to 599, the server errors of RFC 9110

    RFC 9110 names the first digit of a status code its class; 5 is the server error class.

  3. Question 3 of 12 Which file is the only one that knows about the command line and files?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: loglens.nim, the main program

    Keeping files out of the core is what let loglens_node.nim reuse stats.nim and parse.nim unchanged.

  4. Question 4 of 12 stats.nim sorts a seq of (-count, name) pairs. Why minus the count?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Pairs sort in ascending order, field by field, so minus the count puts the largest count first and names break ties

    Encoding the order in the key keeps the sort call trivial; the Python version of the lab does the same.

  5. Question 5 of 12 parse.nim allows at most nine digits of latency. What does that limit protect?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Every latency fits a 32-bit int, the int of the JavaScript backend, and twenty digits are malformed instead of a crash

    A limit in the specification is also a limit on what the code must handle, on every backend.

  6. Question 6 of 12 A Python twin of loglens reads the status with int(field) after checking only len(field) == 3. Which line would it count that loglens refuses?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: GET /a ٢٠٠ 5ms, whose status is three Arabic-Indic digits

    int() documents signs, leading zeros, whitespace, underscores and the digits of every script. Check characters first, as the lab's first step asks.

  7. Question 7 of 12 Which rank does the nearest-rank p95 take for 20 latencies?

    Type a number.

    Show the answer: Question 7 of 12

    Answer: 19

    (95 × 20 + 99) div 100 = 1,999 div 100 = 19: the 19th of the 20 sorted latencies, 120 ms in percentiles.py.

  8. Question 8 of 12 Why did statistics.quantiles report 224.5 ms, a latency that no request had?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: It interpolates between the two nearest values, here the 19th and the 20th

    Nearest rank picks a value; interpolating methods compute one between values. Both are documented definitions.

  9. Question 9 of 12 A bug report says loglens counts GET /a 200 5ms\r as malformed on one machine. Which test do you add first?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: A black-box case: a log with Windows line endings and its expected report, run on both builds

    Choose the test that exercises the place where the fault can be: reading the file is outside the modules the unit tests call.

  10. Question 10 of 12 Why can the five black-box cases prove that the Node.js build is correct, though its main file is different?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: They compare only what each program prints with the expected file, so any main that gives the same bytes passes

    A shared core and a test suite that judges output make a second backend cheap to trust.

  11. Question 11 of 12 Why is loglens's budget stated in processor time, user plus sys, rather than real time?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: Processor time counts the program's own work; real time also grows when the machine is busy with other work

    Measure the program's own work when you compare builds; the clock includes everything else the machine does.

  12. Question 12 of 12 You ship loglens to teams who run it on logs from their own servers. Which build, and why not -d:danger?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Release: fast, and its checks still stop on a bug instead of printing a wrong report

    The first lesson of this mini-course gave the rule, and the project confirmed it: ship -d:release.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 in stats.nim and the files in loglens.nim, reads with file.lines one line at a time, and changes the table through mgetOrPut instead of a copy.
  • scanf with $i accepted +200, 0200 and 2_00 and 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.