InstalmentMilestones 5–8

Instalment 12 · Course 3 (Perl) · Milestones 1–4

From a one-liner to something that survives the data

A filter, then a report, then a parser that treats broken input as evidence rather than an error, then a data model that lets everything downstream forget which format anything came from.

Verification note

Perl 5.38.2. Every program was run against a 2,004-line fixture containing four deliberately broken lines, and against a 200,400-line copy for timings. All tests pass. The throughput numbers at the end of Milestone 4 include one that is genuinely uncomfortable, which is why it is there.

Milestone 1A filter that counts what it reads

Goal

The Mewlang cat, typing on a laptopThe smallest useful program: read files or standard input, count what you find, report it, and exit with a status that means something. No modules, no objects, no parsing.

Concepts

while (<>), $ARGV and $., exit codes as an interface, warn versus print, heredocs, and why a Unix filter is the right default shape.

Design

Before writing anything, decide what kind of program this is. A Perl data tool is almost always a filter: it reads a stream, writes a stream, says nothing else on standard output, and reports its verdict in the exit code. Getting that shape right at the start means the tool composes with everything else on the machine, and getting it wrong means people wrap your program in a shell script to make it behave.

Three rules, which the whole course follows:

Implementation

#!/usr/bin/env perl
use v5.36;

# strata-scan: the smallest useful thing. Count lines, bytes and matches
# across files or standard input, and say something true about the result.

my $pattern = qr/\b(?:ERROR|FATAL)\b/;
my $quiet   = 0;
my @files;

# Option handling by hand for now; Getopt::Long arrives in milestone 10.
while (@ARGV) {
    my $arg = shift @ARGV;
    if    ($arg eq "--pattern") { $pattern = qr/@{[ shift @ARGV ]}/ }
    elsif ($arg eq "--quiet")   { $quiet = 1 }
    elsif ($arg eq "--help")    { print usage(); exit 0 }
    elsif ($arg =~ /^--/)       { die "strata-scan: unknown option $arg\n" . usage() }
    else                        { push @files, $arg }
}
@ARGV = @files;

my ($lines, $bytes, $matched, $empty, $longest) = (0, 0, 0, 0, 0);
my %per_file;

while (my $line = <>) {
    $lines++;
    $bytes += length $line;
    chomp $line;

    $empty++ if $line =~ /^\s*$/;
    $longest = length $line if length $line > $longest;

    if ($line =~ $pattern) {
        $matched++;
        $per_file{$ARGV}++;
    }
}

if ($lines == 0) {
    warn "strata-scan: no input\n";
    exit 2;
}

unless ($quiet) {
    printf "%d lines, %s bytes, %d matched, %d blank, longest %d chars\n",
        $lines, commify($bytes), $matched, $empty, $longest;

    for my $file (sort { $per_file{$b} <=> $per_file{$a} || $a cmp $b } keys %per_file) {
        printf "  %-40s %6d\n", $file, $per_file{$file};
    }
}

exit($matched > 0 ? 1 : 0);

sub commify ($n) { 1 while $n =~ s/^(\d+)(\d{3})/$1,$2/; return $n }

sub usage {
    return <<~"USAGE";
        usage: strata-scan [--pattern REGEX] [--quiet] [FILE...]
               reads standard input when no files are named
               exit 0: no matches   1: matches found   2: no input
        USAGE
}

Explanation

Running it

$ ./bin/strata-scan share/fixtures/access.log
2004 lines, 211,017 bytes, 0 matched, 1 blank, longest 128 chars
$ echo $?
0

$ ./bin/strata-scan --pattern '" 5\d\d ' share/fixtures/access.log
2004 lines, 211,017 bytes, 398 matched, 1 blank, longest 128 chars
  share/fixtures/access.log                   398
$ echo $?
1

$ head -50 share/fixtures/access.log | ./bin/strata-scan --pattern 'Googlebot'
50 lines, 5,298 bytes, 19 matched, 0 blank, longest 127 chars
  -                                            19

$ ./bin/strata-scan < /dev/null
strata-scan: no input
$ echo $?
2

The same binary, three invocation styles, no code paths for any of them. That is <> earning its place.

Manual parsing vs the Unix-oriented default
Python                                    Perl
──────                                    ────
import sys, re, fileinput                 use v5.36;
pattern = re.compile(r'\bERROR\b')
count = 0                                 while (<>) {
for line in fileinput.input():                $n++ if /\bERROR\b/;
    if pattern.search(line):              }
        count += 1
        name = fileinput.filename()
print(count)                              say $n;
sys.exit(1 if count else 0)

Python's fileinput exists precisely because this shape is useful, and it is an import plus a function call plus a compiled pattern object. In Perl the same thing is the default syntax. That is the whole ergonomic argument, and over a large tool it is the difference between the plumbing being visible and being invisible.

Exercise 1

Extend strata-scan with --histogram FIELD, which splits each line on whitespace and prints a count of the values in that column, sorted by frequency, with a bar made of # characters scaled to the terminal width (assume 80 if you cannot detect it). It must still stream.

Then add --sample N, which prints N randomly chosen matching lines using reservoir sampling, so that it works on a stream of unknown length without storing more than N lines.

Hint for the second part: keep the first N; for item i after that, replace a random one of the N with probability N/i. Look up why that gives a uniform sample before implementing it.

Solution 1 — open after trying
my (@reservoir, $seen);

sub reservoir_add ($line, $n) {
    $seen++;
    if (@reservoir < $n) {
        push @reservoir, $line;
    } else {
        my $j = int rand $seen;          # 0 .. seen-1
        $reservoir[$j] = $line if $j < $n;
    }
}

# histogram
my %hist;
# ... inside the loop:
if (defined $field) {
    my @cols = split ' ', $line;
    $hist{ $cols[$field] // "(missing)" }++;
}

# ... after the loop:
my $width = ($ENV{COLUMNS} || 80) - 30;
my ($max) = sort { $b <=> $a } values %hist;
for my $key (sort { $hist{$b} <=> $hist{$a} || $a cmp $b } keys %hist) {
    my $bar = "#" x int($width * $hist{$key} / $max);
    printf "  %-18s %7d %s\n", substr($key, 0, 18), $hist{$key}, $bar;
}

Three things worth extracting.

  • Reservoir sampling is the streaming answer to "give me a representative subset". It uses O(N) memory for a stream of any length, and the proof that it is uniform is short enough to reconstruct: the i-th item enters with probability N/i, and each earlier item survives each subsequent step with exactly the right probability. You will use this again in Milestone 7, where sampling a 200 GB file is the only way to look at it.
  • Scaling the bar to the maximum, not the total, is what makes a histogram readable when one value dominates. Getting this backwards produces a chart where everything after the first row is invisible.
  • $cols[$field] // "(missing)" because short lines exist. In this course, every access to data that came from a file needs an answer for "what if it is not there", and the answer is never "crash".

Experiment

Add print STDERR "$ARGV:$.\n"; right after $lines++; and run strata-scan over two small files at once. $. does not reset at the file boundary: the second file's first line reports as continuing the count from the first, not restarting at 1. Add close ARGV if eof; at the bottom of the loop and run again, and each file now starts back at line 1. Milestone 2's push @first_failures, "$ARGV:$." if ... depends on exactly this number meaning "line within this file", so which behaviour you want is a decision to make deliberately, not a default to inherit by accident.

Common mistakes in Milestone 1

  • Printing diagnostics to stdout. Your tool stops being pipeable.
  • Forgetting to restore @ARGV after hand-parsing options, so <> reads nothing (or tries to open --quiet as a file).
  • Using exit 1 for "could not run". Reserve 1 for "ran and found something"; use 2 or higher for usage and I/O errors.
  • Counting bytes after chomp, which silently undercounts by one per line.
  • Assuming $. resets between files. It does not, unless you close ARGV if eof; at the end of the loop.

Checkpoint

  1. What does <> read from, and how does it decide?
  2. Why does the program reassign @ARGV before the loop?
  3. What are $ARGV and $., and what is $ARGV when reading a pipe?
  4. What do the three exit codes mean and who consumes them?
  5. Why is warn preferable to print for the "no input" message?

Milestone 2Reports: one pass, several questions

Goal

Turn the counting filter into something that answers real questions about a log: who is talking most, what is failing, when the spikes were, and what could not be parsed. Still one file, still one pass, still constant memory in the size of the input.

Concepts

Hashes as accumulators, multi-key sorting, list slices, printf report formatting, and the discipline of computing everything in a single traversal.

Design

The instinct from other languages is to read the file into a list and then run four separate passes over it. Do not. Every question you can answer with a counter should be answered during the single pass you were already making, because that is what keeps memory proportional to the number of distinct keys rather than to the size of the input. A log with 200 million lines and 4,000 client addresses needs 4,000 hash entries, not 200 million.

So the shape is: declare one hash per question, update them all in the loop, and do the sorting and formatting afterwards.

Implementation

my %stat = (total => 0, parsed => 0, failed => 0);
my (%hits, %bytes, %errors, %by_minute, %by_class, %by_path, @first_failures);

while (my $line = <>) {
    $stat{total}++;

    unless ($line =~ $APACHE) {
        $stat{failed}++;
        push @first_failures, "$ARGV:$." if @first_failures < 3;
        next;
    }
    $stat{parsed}++;

    my %f = %+;                                   # copy before the next match
    my $bytes = $f{bytes} eq "-" ? 0 : $f{bytes};

    $hits{ $f{ip} }++;
    $bytes{ $f{ip} } += $bytes;
    $errors{ $f{ip} }++ if $f{status} >= 400;
    $by_class{ substr($f{status}, 0, 1) . "xx" }++;
    $by_minute{$1}++ if $f{ts} =~ m{^(\d+/\w+/\d+:\d+:\d+)};
    $by_path{$1}++   if $f{request} =~ m{^[A-Z]+ \s+ ([^?\s]+)}x;
}

Then a reusable top helper, because "the five biggest" appears four times:

# top(n, \%hash) returns the n keys with the largest values, ties broken
# alphabetically so the report is reproducible.
sub top ($n, $href) {
    my @keys = sort { $href->{$b} <=> $href->{$a} || $a cmp $b } keys %$href;
    return grep { defined } @keys[0 .. $n - 1];
}

Running it

$ ./bin/strata-report share/fixtures/access.log

top talkers
  10.14.22.9         1020 hits     23,671,329 bytes   38.9% errors
  10.14.22.31         344 hits      7,822,046 bytes   41.0% errors
  192.168.4.7         324 hits      7,559,417 bytes   37.7% errors
  172.16.0.99         312 hits      7,061,075 bytes   36.9% errors

status classes
  2xx       1028   51.4%
  3xx        197    9.8%
  4xx        377   18.9%
  5xx        398   19.9%

busiest minutes
  12/Sep/2026:13:56             35
  12/Sep/2026:13:36             34
  12/Sep/2026:13:41             34

most requested paths
  /                            361
  /api/export                  342
  /api/search                  336

2,004 lines, 2,000 parsed, 4 unparsed (0.20%)
  first failures: share/fixtures/access.log:2001, ...:2002, ...:2003

Four questions answered in one pass over the file, with the unparsed lines counted and located rather than silently skipped. That last line is the most important one in the output, and Milestone 3 is about taking it seriously.

The Mewlang cat, visibly startledThe report quietly lies, and the failure count is how you find out

Line 2001 of the fixture is this:

10.14.22.9 - - [12/Sep/2026:13:44:10 +0000] "GET /api/export HTTP/1.1" 200

A perfectly ordinary request that happens to have no byte count, which some configurations and some proxies produce. Our pattern requires (?<bytes>\d+|-), so the line does not match, and it is counted as garbage alongside this is not a log line at all.

At 0.2% nobody notices. But imagine the same regex meeting a server that omits byte counts on all 304 responses: you would silently discard every cache hit in the file and report confidently on the rest. The failure rate is the only signal that your parser and your data disagree, which is why it is printed on every run and why the exit code depends on it. Milestone 3's parser distinguishes "I could not read this at all" from "I read this but something was odd", and that distinction is the difference between a tool you can trust and one that produces plausible numbers.

Four passes over the data vs one
pandas                                      Perl
──────                                      ────
df = pd.read_csv(log, sep=None)             while (my $line = <>) {
top = df.groupby("ip").size() \                 $hits{$f{ip}}++;
        .sort_values(ascending=False)           $errors{$f{ip}}++ if $f{status} >= 400;
errs = df[df.status >= 400] \                   $by_class{...}++;
        .groupby("ip").size()                   $by_minute{...}++;
classes = df.status.astype(str).str[0]      }
by_min = df.ts.dt.floor("min") \
        .value_counts()

The pandas version is not wrong, and for a file that fits comfortably in memory it reads well: one line per question. But it makes four full scans of the loaded table (five, counting the parse), and every one of them assumes the whole thing is already sitting in RAM. The Perl loop makes one pass over the stream and updates four hashes as it goes, holding nothing but the current line and a handful of counters at any moment. On a file that does not fit in memory — which is the normal case for a real log — only one of these approaches runs at all.

Exercise 2
  1. Percentiles. Add response-size percentiles (p50, p90, p99) per client. The naive approach stores every value; think about what that costs on a 200 million line log, then implement it anyway for now and write down the memory you would need.
  2. Rate of change. Report the minute with the largest increase in requests over the previous minute, not just the busiest. This needs the minutes in chronological order, which hash keys are not.
  3. A second dimension. Report the top three paths per status class, which means a hash of hashes and therefore your first real nested structure.
Solution 2 — open after trying

1. Percentiles. The honest version first:

push @{ $sizes{ $f{ip} } }, $bytes;          # O(n) memory, exact

sub percentile ($values, $p) {
    my @sorted = sort { $a <=> $b } @$values;
    return $sorted[ int($p / 100 * $#sorted) ];
}

For 200 million lines at roughly 24 bytes per stored number plus Perl's scalar overhead (around 60 bytes each in an array), that is well over 10 GB. Unacceptable, and the fix is the same one every metrics system uses: bucket the values.

# Log-scale buckets: cheap, bounded, accurate to a factor of two.
$size_buckets{ $f{ip} }[ $bytes ? int(log($bytes) / log(2)) : 0 ]++;

Sixty-four counters per client instead of a million values, and the p99 you read off it is accurate to within a factor of two. That is the same trade as the Go course's histogram, and the same conclusion: for alerting and comparison it is plenty; for a contractual SLA it is not.

2. Rate of change needs sorted keys, and the keys are strings like 12/Sep/2026:13:56 which do not sort chronologically (September sorts before October alphabetically only by luck, and 2026 before 2027 only because the day comes first). This is your first encounter with the problem Milestone 8 solves properly: you cannot order timestamps until you have normalised them. The stopgap is to key by epoch seconds:

use Time::Piece;
my $t = Time::Piece->strptime($f{ts}, "%d/%b/%Y:%H:%M:%S %z");
$by_minute{ $t->epoch - $t->epoch % 60 }++;

Numeric keys sort correctly, and converting back for display is a formatting concern. Note that strptime is roughly ten times slower than a regex, which is why Milestone 8 discusses when to parse a timestamp and when to leave it as a string.

3. Two dimensions is one character of syntax and a new habit:

$by_class_path{ substr($f{status},0,1) . "xx" }{ $path }++;   # autovivified

for my $class (sort keys %by_class_path) {
    my $paths = $by_class_path{$class};
    say "  $class";
    printf "    %-24s %6d\n", $_, $paths->{$_} for top(3, $paths);
}

Autovivification means the inner hash appears when first used. Note that top already takes a hash reference, so it works unchanged on the inner hash: writing helpers that take references rather than hashes is what makes them composable in Perl.

Experiment

Delete || $a cmp $b from top's sort block and run strata-report on the same fixture several times in a row. Whenever two clients tie on hits, their order in the "top talkers" table changes between runs, because Perl randomises hash key order per process by design (so that an attacker cannot craft input designed to degrade every hash to worst-case behaviour). Put the tiebreaker back and the order becomes stable again, run after run. A report whose row order depends on the interpreter's random seed is not a bug you find by reading the code; it is a bug you find when two runs over the same input disagree, which is exactly the kind of thing a forensic tool cannot afford.

Checkpoint

  1. Why compute every statistic in one pass rather than several?
  2. What is memory proportional to in this program, and what is it not proportional to?
  3. Why does top need a tiebreaker?
  4. Why must %+ be copied into %f immediately?
  5. What did the failure count reveal about the fixture, and why does that matter more than the 0.2%?

Milestone 3A parser that treats broken input as evidence

Goal

Extract the regex into a reusable pattern library and a real parser module. Handle both common and combined log formats with one pattern. Distinguish three outcomes: parsed cleanly, parsed with problems, and not parsed at all. Count every problem by kind, and never lose a line.

Concepts

qr// as composable values, regex composition by interpolation, optional groups, a parser as an object with statistics, strict versus lenient modes, and format detection scoring.

Design

Two design decisions carry the whole project.

1. A pattern library, not scattered regexes. The same notions (an IPv4 address, a timestamp, a quoted string) appear in every format we will parse. Defining them once as named qr// values means they can be composed, tested, and fixed in one place.

2. parse always returns a record. Never undef, never an exception. A line that cannot be understood comes back with a problem field and its raw text intact. This sounds like a small API choice and it is the difference between a forensic tool and a reporting script: the lines you cannot parse are frequently the interesting ones. A truncated line marks where the disk filled; garbage in a log marks where something wrote to the wrong file descriptor; an unparsable request line is often an attack.

Three outcomes, not two:

OutcomeLooks likeCaller should
cleanfields, no problemsuse it
usable with problemsfields, plus problems => ["missing_bytes"]use it, and know the caveat
unparsedproblem => "no_match", raw, provenanceinvestigate it

Implementation

lib/Strata/Pattern.pm

package Strata::Pattern;
use v5.36;
use Exporter 'import';

our @EXPORT_OK = qw(pattern compose %PATTERN);

# A library of named, reusable pieces. Each is compiled once with qr// and
# can be interpolated into a larger pattern, which is how you build a big
# regex you can still read a year later.
our %PATTERN = (
    ipv4      => qr/(?:\d{1,3}\.){3}\d{1,3}/,
    ipv6      => qr/(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}/,
    host      => qr/[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9-]+)+/,
    email     => qr/[A-Za-z0-9._%+-]+ @ [A-Za-z0-9.-]+ \. [A-Za-z]{2,}/x,
    uuid      => qr/[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/,
    unix_path => qr{/(?:[\w.\-~%]+/?)*},
    quoted    => qr/"[^"]*"/,

    # 12/Sep/2026:13:44:10 +0000
    ts_apache => qr{\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}\s[+-]\d{4}},
    # 2026-09-12T13:44:10.123Z  or  2026-09-12 13:44:10
    ts_iso    => qr/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/,
    # Sep 12 13:44:10   (syslog, no year: the format that ruins your day)
    ts_syslog => qr/\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}/,

    level     => qr/\b(?:TRACE|DEBUG|INFO|NOTICE|WARN(?:ING)?|ERROR|FATAL|CRIT(?:ICAL)?)\b/,
);

sub pattern ($name) {
    return $PATTERN{$name} // die "Strata::Pattern: no pattern named '$name'\n";
}

# compose("ip", "ts_apache") returns an alternation of named patterns,
# useful for "find any of these anywhere" scanning.
sub compose (@names) {
    my @parts = map { pattern($_) } @names;
    my $alt = join "|", map { "(?:$_)" } @parts;
    return qr/$alt/;
}

1;

Interpolating a qr// into another pattern is the feature that makes this work. A compiled pattern stringifies to something like (?^u:\d{1,3}\.), carrying its own flags, so pasting it into a larger pattern preserves whether it was /x or /i. That is why $PATTERN{email} can be written with /x for readability and still work when composed into a pattern that is not.

Note the comment on ts_syslog. Syslog timestamps have no year, which means correlating a syslog file with anything else requires guessing the year from the file's modification time and handling the December-to-January rollover. Writing that down in the pattern library, where someone will read it, is worth more than the pattern itself. Milestone 8 has to solve it.

lib/Strata/Parser/Apache.pm — one pattern, two formats

my $ACCESS = qr{
    ^ (?<client>\S+) \s+                       # client address (or hostname)
      (?<ident>\S+)  \s+                       # RFC 1413 identity, almost always -
      (?<user>\S+)   \s+                       # HTTP auth user, or -
      \[ (?<ts>[^\]]+) \] \s+                  # [12/Sep/2026:13:44:10 +0000]
      " (?<request>[^"]*) " \s+                # "GET /path HTTP/1.1"
      (?<status>\d{3}) \s*                     # 200
      (?<bytes>\d+|-)?                         # response size, sometimes absent
      (?: \s+ " (?<referer>[^"]*) "
          \s+ " (?<agent>[^"]*) " )?           # the combined-format tail
      \s* $
}x;

Compare that with Milestone 2's one-liner version. Same job, and this one can be read, annotated, and modified by someone who has never seen an Apache log. Four specific improvements:

The parser object

sub parse ($self, $line, %ctx) {
    chomp $line;

    my %rec = (
        source => $self->{name},
        file   => $ctx{file} // "-",
        lineno => $ctx{lineno} // 0,
        raw    => $line,
    );

    if ($line !~ /\S/) {
        return $self->_problem(\%rec, "blank");
    }
    unless ($line =~ $ACCESS) {
        return $self->_problem(\%rec, "no_match");
    }

    my %f = %+;                      # copy now: the next match resets %+

    $rec{client} = $f{client};
    $rec{user}   = ($f{user} // "-") eq "-" ? undef : $f{user};
    $rec{ts_raw} = $f{ts};
    $rec{status} = 0 + $f{status};
    $rec{bytes}  = (!defined $f{bytes} || $f{bytes} eq "-") ? 0 : 0 + $f{bytes};
    $rec{format} = defined $f{agent} ? "combined" : "common";

    if (!defined $f{bytes}) {
        push @{ $rec{problems} }, "missing_bytes";
        $self->{problems}{missing_bytes}++;
        return $self->_problem(\%rec, "missing_bytes") if $self->{strict};
    }

    if ($f{request} =~ $REQUEST) {
        my %r = %+;
        @rec{qw(method path proto)} = @r{qw(method path proto)};
        ($rec{path_base}, $rec{query}) = split /\?/, $r{path}, 2;
    } else {
        push @{ $rec{problems} }, "bad_request_line";
        $rec{request_raw} = $f{request};
        return $self->_problem(\%rec, "bad_request_line") if $self->{strict};
    }

    $self->{parsed}++;
    return \%rec;
}

Format detection, cheaply

# A cheap confidence score, used later to sniff an unknown file's format.
# Deliberately not a full parse: it runs on a handful of sample lines.
sub detect ($class, @lines) {
    my $hits = grep { /^\S+ \s+ \S+ \s+ \S+ \s+ \[[^\]]+\] \s+ "[^"]*" \s+ \d{3}/x } @lines;
    return @lines ? $hits / @lines : 0;
}

A score between 0 and 1 rather than a boolean, because Milestone 6 will ask every parser and pick the most confident. Using a looser pattern than the real one is deliberate: detection should recognise the family, and parsing should handle the details.

The tests, which are the real deliverable

subtest "unparsable input is kept, not thrown away" => sub {
    for my $case (
        ["this is not a log line at all", "no_match"],
        ['10.14.22.9 - - [12/Sep/2026:13:44:11 +0000] "GET /trunc', "no_match"],
        ["", "blank"],
        ["   ", "blank"],
    ) {
        my ($line, $expected) = @$case;
        my $r = $p->parse($line, file => "access.log", lineno => 99);
        is $r->{problem}, $expected, "'" . substr($line, 0, 24) . "' => $expected";
        is $r->{raw}, $line, "  raw text is preserved";
        is $r->{file}, "access.log", "  provenance is preserved";
    }
};

subtest "imperfect but usable" => sub {
    my $line = '10.14.22.9 - - [12/Sep/2026:13:44:10 +0000] "GET /api/export HTTP/1.1" 200';
    my $r = $p->parse($line);

    is $r->{status}, 200, "we still got the status";
    is $r->{bytes},  0,   "missing bytes defaults to 0";
    is_deeply $r->{problems}, ["missing_bytes"], "the problem is recorded";
    ok !$r->{problem}, "but the record is still usable";

    my $strict = Strata::Parser::Apache->new(strict => 1);
    is $strict->parse($line)->{problem}, "missing_bytes", "strict mode rejects it";
};
$ prove -l t/20-parser-apache.t
t/20-parser-apache.t .. ok
All tests successful.

Points of technique worth copying. subtest groups related assertions and names them, so a failure says which behaviour broke rather than which line number. A loop over a list of [input, expected] pairs is Perl's table-driven test, and adding a newly discovered broken line is one line. is_deeply compares nested structures, which is how you assert on an arrayref without writing four assertions.

Most importantly: the interesting tests are the failure cases. Six of the assertions concern lines that do not parse, because the whole point of the parser is what it does when the data is wrong.

Splitting on whitespace vs a pattern that knows about quotes
awk (typical)                                     Perl
─────────────                                     ────
awk '{print $1, $7, $9}' access.log               if ($line =~ $ACCESS) {
  # breaks the moment a quoted field                   my %f = %+;
  # contains a space, e.g. a request                   # $f{request} is the whole
  # line with a spaced-out query string                # quoted field, spaces and all

awk's default field splitting has no concept of quoting: a request line like "GET /search?q=hello world HTTP/1.1" — a real, if unusual, query string — splits into extra fields and shifts every column after it, silently corrupting the status code and byte count columns awk thinks it is reading. The regex's [^"]* group captures the whole quoted field as one named capture whatever it contains, because it is driven by the quote characters rather than by whitespace. The awk one-liner is shorter to type and just as fast on a well-formed line; it is answering a simpler question than the log format actually asks, and the gap only shows up on the inputs you did not think to test by hand.

The Mewlang cat, giving a playful winkExercise 3
  1. An nginx error-log parser. It looks like 2026/09/12 13:44:09 [error] 8823#0: *4412 upstream timed out, client: 10.14.22.9, server: api, request: "GET /api/export HTTP/1.1". Write Strata::Parser::NginxError with the same contract: always returns a record, records problems, counts by kind, and has a detect. Note that the trailing key-value pairs are a variable set, so parse them generically rather than naming each one.
  2. Fuzz your parser. Take 200 good lines, and for each produce a mutant: truncate at a random point, delete a random character, insert a stray quote, or double a field. Assert that parse never dies, never hangs, and always returns a hashref with a raw field. Fix whatever this finds.
  3. Catastrophic backtracking. Construct a line that makes a naive version of the pattern (using "(.*)" for the request field) take more than a second, and show that the [^"]* version does not. Then explain why.
Solution 3 — open after trying

1. The generic key-value tail is the interesting part:

my $NGINX_ERROR = qr{
    ^ (?<ts>\d{4}/\d{2}/\d{2} \s \d{2}:\d{2}:\d{2}) \s+
      \[ (?<level>\w+) \] \s+
      (?<pid>\d+)\#(?<tid>\d+): \s*
      (?: \*(?<connection>\d+) \s+ )?
      (?<message>.+?)
      (?: , \s+ (?<tail>\w+: \s .*) )? $
}x;

# then, for the tail:
if (defined $f{tail}) {
    while ($f{tail} =~ /(\w+): \s+ ( "[^"]*" | [^,]+ )/gx) {
        my ($k, $v) = ($1, $2);
        $v =~ s/^"|"$//g;
        $rec{"ctx_$k"} = $v;
    }
}

Two lessons. Prefix generically-extracted keys (ctx_) so they cannot collide with fields you control; otherwise a log line containing status: hacked overwrites your parsed status. And the alternation ( "[^"]*" | [^,]+ ) handles values that are quoted and may contain commas, which the naive [^,]+ gets wrong on exactly the lines you care about.

2. Fuzzing.

my @mutators = (
    sub ($l) { substr($l, 0, int rand length $l) },              # truncate
    sub ($l) { my $i = int rand length $l; substr($l, $i, 1) = ""; $l },
    sub ($l) { my $i = int rand length $l; substr($l, $i, 0) = '"'; $l },
    sub ($l) { $l . $l },
    sub ($l) { my $i = int rand length $l; substr($l, $i, 1) = "\x{263A}"; $l },
);

for my $line (@good_lines) {
    for my $mutate (@mutators) {
        my $mutant = $mutate->($line);
        my $rec = eval { $p->parse($mutant) };
        ok defined $rec, "survived: " . substr($mutant, 0, 30);
        ok exists $rec->{raw}, "kept the raw text";
    }
}

Wrapping in eval and asserting it was not needed is the point: the test passes only if no mutant made the parser die. Include a Unicode mutation, because encoding surprises are the most common real-world corruption and Milestone 7 is about them.

3. Catastrophic backtracking. A pattern with a quantified group that can match the same text several ways, followed by something that fails, forces the engine to try every combination. With "(.*)" \s+ (\d{3}) against a line containing many quotes and no valid status, the engine tries every possible split. The measurable version:

my $evil = '10.0.0.1 - - [x] "' . ('a" "' x 20) . 'GET / HTTP/1.1" xxx';

[^"]* cannot backtrack across a quote, so there is exactly one way to match each field and the engine fails immediately. The general rule: make each part of a pattern match exactly one thing, and the regex engine never has to guess. Perl 5.10+ also offers possessive quantifiers (.*+) and atomic groups ((?>...)), which forbid backtracking explicitly; use them when a negated class is not available.

Experiment

Change (?<bytes>\d+|-)? in $ACCESS to drop the trailing ?, making the byte count mandatory, and re-parse the line from the Milestone 2 bug report: ... "GET /api/export HTTP/1.1" 200 with no trailing bytes field. It no longer matches at all: the record Milestone 3 exists to rescue — status, method and path preserved, only missing_bytes flagged — goes back to being indistinguishable from no_match garbage. That single ? is the entire difference between "lossy but usable" and "silently gone", worth watching fail once before trusting it in the four parsers still to come.

Common mistakes in Milestone 3

  • Returning undef for unparsable lines. You have just discarded the evidence.
  • Throwing an exception per bad line. One malformed line in a million should not need a try block in the caller's hot loop.
  • Not copying %+ before the second match. The request-line match resets it.
  • Forgetting chomp in the parser, so $ in the pattern has to cope with a newline (it does, once, which makes the bug intermittent and confusing).
  • Making detect as strict as parse. Detection should recognise a family, loosely and cheaply.
  • Letting a generically-extracted key overwrite a parsed field. Prefix them.

Checkpoint

  1. Why does parse never return undef?
  2. What are the three outcomes and how does a caller tell them apart?
  3. How does one pattern handle both common and combined logs, and why not two patterns?
  4. Why does the parser store 0 + $f{status} rather than the string?
  5. What does interpolating a qr// into another pattern preserve?
  6. Why is detect deliberately looser than parse?

Milestone 4A Record and a Pipeline

Goal

Stop passing bare hashes around. A Record carries fields, provenance, problems and extracted entities, and knows how to render itself. A Pipeline reads a source, parses it, wraps the result, and passes it through a list of stages. Nothing downstream ever learns which format a record came from.

Concepts

bless and accessor methods, closures as pipeline stages, code references in data structures, in-memory filehandles for testing, and the cost of abstraction measured rather than assumed.

Design

The pipeline is the project's spine, so its contract should be as small as possible:

  source (filehandle)
      │  one line
      ▼
  parser->parse(line, file => ..., lineno => ...)   returns a plain hash
      ▼
  Strata::Record->from_parsed(...)                  wraps it
      ▼
  stage 1 -> stage 2 -> stage 3                     each: ($rec, $pipe) -> $rec or undef
      ▼
  counted, and forgotten                            nothing accumulates records

Three consequences of that shape, all deliberate:

Implementation

lib/Strata/Record.pm

# Build a Record from the plain hash a parser returns. Keeping this in one
# place means parsers stay simple and the Record owns the shape.
sub from_parsed ($class, $parsed) {
    my %copy = %$parsed;
    my %meta;
    $meta{$_} = delete $copy{$_} for qw(source file lineno raw problems);

    return $class->new(
        %meta,
        fields   => \%copy,
        problems => $meta{problems} // [],
    );
}

sub problems ($self) { @{ $self->{problems} } }
sub ok       ($self) { !@{ $self->{problems} } }
sub where    ($self) { $self->{file} . ":" . $self->{lineno} }

sub field ($self, $name, @set) {
    $self->{fields}{$name} = $set[0] if @set;
    return $self->{fields}{$name};
}

lib/Strata/Pipeline.pm

sub run_handle ($self, $fh, $name) {
    while (defined(my $line = <$fh>)) {
        $self->{stats}{lines}++;
        my $parsed = $self->{parser}->parse($line, file => $name, lineno => $.);
        my $record = Strata::Record->from_parsed($parsed);

        $self->{stats}{problems}++ if !$record->ok;
        $self->{problems_by_kind}{$_}++ for $record->problems;

        my $kept = $record;
        for my $stage (@{ $self->{stages} }) {
            $kept = $stage->{code}->($kept, $self) or last;
        }

        if ($kept) { $self->{stats}{records}++ }
        else       { $self->{stats}{dropped}++ }
    }
    return $self;
}

while (defined(my $line = <$fh>)), not while (my $line = <$fh>). A line containing just "0" with no newline is false in Perl, so the plain form stops early on a file whose last line is a bare zero. Perl special-cases while (<$fh>) to add the defined for you, but only for that exact shape; the moment you write anything else, you need it yourself. This is a genuine bug that appears once every few years and takes an afternoon.

$stage->{code}->($kept, $self) or last — call the code reference, and stop the chain when it returns false. Perl's or has very low precedence, which is exactly what makes this read as "do this, or else stop".

Stages are closures

# counter("status") returns a stage and a hashref it fills in.
sub counter ($field) {
    my %counts;
    my $stage = sub ($rec, $) {
        my $value = $rec->field($field);
        $counts{ defined $value ? $value : "(undef)" }++;
        return $rec;
    };
    return ($stage, \%counts);
}

sub drop_problems () {
    return sub ($rec, $) { return $rec->ok ? $rec : undef };
}

sub sample ($n, $out) {
    my $seen = 0;
    return sub ($rec, $) {
        push @$out, $rec if $seen++ < $n;
        return $rec;
    };
}

Returning both the stage and the hash it fills is the key move. %counts is a lexical variable captured by the closure; the caller gets a reference to it and can read the results after the run, but nobody can reach it except through the stage. That is encapsulation without a class, and it is the most Perl-ish thing in this milestone.

sub ($rec, $) — a signature with an unnamed second parameter. It accepts the pipeline argument and says plainly that this stage ignores it, which is better than accepting a parameter you never mention.

Testing with in-memory filehandles

sub fh_for (@lines) {
    my $text = join "\n", @lines, "";
    open my $fh, "<", \$text or die $!;   # an in-memory filehandle
    return $fh;
}

open $fh, "<", \$string opens a scalar as a file. No temporary files, no fixtures on disk for the small cases, no cleanup, and the tests run in milliseconds. Every language should have this and most make you reach for a library. Use real fixture files for the realistic cases and in-memory handles for the focused ones.

$ prove -l t/
t/20-parser-apache.t .. ok
t/30-pipeline.t ....... ok
All tests successful.
Files=2, Tests=9

Putting it together

$ ./bin/strata ingest share/fixtures/access.log
  share/fixtures/access.log                    2004 lines

2,004 lines, 2,004 records, 4 with problems, 2004 lines/sec

problems
  blank                     1
  missing_bytes             1
  no_match                  2

first offenders
  share/fixtures/access.log:2001  10.14.22.9 - - [12/Sep/2026:13:44:10 +0000] "G
  share/fixtures/access.log:2002  this is not a log line at all
  share/fixtures/access.log:2003  10.14.22.9 - - [12/Sep/2026:13:44:11 +0000] "G

top clients
  10.14.22.9          1021
  10.14.22.31          344
  192.168.4.7          324
  172.16.0.99          312
  (undef)                3

status codes
  (undef)       3
  200       1029
  ...

Compare with Milestone 2: the same file now yields 2,004 records instead of 2,000, because the missing-bytes line is parsed rather than discarded, and the three genuinely unparsable lines are present as records with problems rather than absent. Client 10.14.22.9 gained the request it was previously denied.

The (undef) rows are the new behaviour being honest: those are the unparsable records flowing through counters that expect fields. In a real report you would put drop_problems() before the counters, or have the counter skip records that are not ok. Seeing them is better than not seeing them, and choosing where to drop them is now a one-line decision in the pipeline rather than a property of the parser.

What the abstraction costs

Three programs, the same 200,400-line file, same machine:

./bin/strata-scan                 0.37s   544,525 lines/sec    (count and match only)
./bin/strata-report               1.72s   116,322 lines/sec    (regex + six hashes)
./bin/strata ingest               5.47s    36,668 lines/sec    (Record objects + stages)

And memory, peak resident set on a 21 MB file:

strata ingest (streaming)                     9,036 KB
bare read loop                                4,992 KB
slurping the file into an array              43,292 KB

The Mewlang cat, unimpressedTwo findings, and the second one is uncomfortable.

Streaming works. The pipeline uses 9 MB regardless of file size, while slurping a 21 MB file into an array costs 43 MB, about twice the file, because every line becomes a Perl scalar with its own overhead. On a 21 GB file that is 43 GB and the difference between a tool and an outage.

The object pipeline is three times slower than the flat script, and fifteen times slower than counting. That is the price of one blessed hash and two closure calls per line, and it is real: 36,000 lines a second means a 200 GB log takes hours.

Is it worth it? For now, yes, and I want to be precise about why rather than waving at "clean code". The pipeline buys pluggable formats (Milestone 6), reusable stages, provenance that survives, and a testable seam. A flat script buys none of those and would have to be rewritten to gain any of them. But the number is now on the table, it is the reason Milestone 11 exists, and the honest answer at that point may be that the hot loop gets specialised while the architecture stays. This is exactly the trade the Go course made in reverse: there, the profile said channel overhead dominated and we removed it; here, the profile will say object creation dominates, and we will decide what to do with that evidence rather than guessing now.

Piping processes vs piping closures
shell pipeline                                Perl Pipeline
───────────────                                ─────────────
grep ERROR access.log \                        $pipe->add_stage($match_stage)
  | awk '{print $1}' \                              ->add_stage($counter_stage)
  | sort | uniq -c \                                ->add_stage(drop_problems());
  | sort -rn
                                                $pipe->run_handle($fh, $name);

The shell version needs no Perl at all and is genuinely the right tool for a quick count. But each stage is a separate process re-parsing plain text with no memory of where a field came from — by the time uniq -c runs, the line number and source file of the original match are gone, and there is no way to ask "which raw line produced this row" afterwards. The Perl Pipeline carries every record as an object with its provenance intact through every stage, in one process, at the cost — as the throughput numbers later in this milestone show plainly — of being measurably slower than either the shell pipeline or a flat script. Composability and forensic traceability are what that slowdown buys, not abstraction for its own sake.

The Mewlang cat, thinking with a paw to its chinExercise 4
  1. Find the cost. Profile bin/strata with Devel::NYTProf (perl -d:NYTProf bin/strata ingest big.log then nytprofhtml) and report what actually dominates. Predict first, then check: is it bless, the regex, the closures, or something you did not consider?
  2. Make a lightweight record. Implement a variant where a record is a plain hashref with a documented shape and the methods become functions. Measure the difference. Then decide, with the number in hand, which you would ship and write down why.
  3. A stage that needs two records. Write a pair_with_previous stage that attaches the previous record from the same client to each record (as prev), so a later stage can compute inter-request intervals. Bound its memory: it must not keep more than one record per client, and must forget clients not seen for N records.
Solution 4 — open after trying

1. The likely answer is not bless (which is cheap) but the sheer number of hash allocations: parse builds %rec and %f, from_parsed builds %copy and %meta plus the object, so each line allocates five hashes where the flat script allocates one. Perl hashes are not cheap. The regex is usually second, and the closure calls are a distant third.

The lesson to internalise: in Perl, the cost of "clean" is usually allocation, not indirection. That points at a specific fix (build fewer intermediate structures) rather than a vague one (use fewer objects).

2. A plain hashref with functions instead of methods typically recovers much of the gap, because you skip the object and one hash copy. Having measured it, the design conversation becomes concrete. My own answer would be: keep the Record class as the public interface, and have from_parsed bless the parser's hash in place rather than copying it:

sub from_parsed ($class, $parsed) {
    my %meta;
    $meta{$_} = delete $parsed->{$_} for qw(source file lineno raw problems);
    $meta{fields} = $parsed;              # reuse, do not copy
    $meta{problems} //= [];
    $meta{entities} = {};
    return bless \%meta, $class;
}

One hash instead of three, the same API, no caller changes. That is the shape of most good Perl optimisation: stop copying, keep the interface. The cost is that the parser must not reuse the hash it returned, which is now a documented contract rather than an assumption.

3. The bounded memory requirement is the whole exercise:

sub pair_with_previous ($forget_after = 10_000) {
    my (%last, %last_seen, $n);

    return sub ($rec, $) {
        my $client = $rec->field("client") // return $rec;
        $n++;

        $rec->field(prev => $last{$client}) if exists $last{$client};
        $last{$client} = $rec;
        $last_seen{$client} = $n;

        # Periodically forget clients we have not seen recently, so a log
        # with a million distinct clients cannot exhaust memory.
        if ($n % $forget_after == 0) {
            for my $c (keys %last_seen) {
                next if $n - $last_seen{$c} < $forget_after;
                delete $last{$c};
                delete $last_seen{$c};
            }
        }
        return $rec;
    };
}

Three points. Any stage that remembers anything keyed by data needs an eviction policy, because the key space is controlled by the input and therefore by whoever generated it. Periodic sweeps beat per-record checks: doing the cleanup every 10,000 records amortises it to nothing. And holding a reference to the previous record keeps that entire record alive, including its raw line, so a long-lived map of "previous per client" can be much bigger than it looks; storing only the fields you need (the timestamp) would be the frugal version.

Experiment

Take any stage and replace its final return $rec; with a bare trailing print STDERR "stage ran\n"; — exactly the mistake the common-mistakes box below warns about — and put it first in a chain that has a second stage after it. The second stage does not receive the record: it receives the return value of print, which is 1, and the moment it calls $rec->field(...) on that plain scalar, Perl dies with Can't locate object method "field" via package "1". A stage that happened to be last in the chain would have hidden this entirely, since the pipeline only checks truthiness for counting records versus drops — which is exactly why this mistake is easy to introduce and easy not to notice until stages get reordered.

Common mistakes in Milestone 4

  • while (my $line = <$fh>) without defined. Stops on a final line of "0".
  • Accumulating records in the pipeline "just for now". The memory profile is the feature; do not give it away.
  • Opening files inside the pipeline, which makes gzip, stdin and encodings impossible to add later without touching it.
  • A stage that returns nothing by accident (a trailing print, whose return value is 1, is fine; a trailing my $x = ...; is not). A stage must return the record.
  • Forgetting that $. is the line number of the last handle read, which is right here because there is one handle, and will need care when you nest readers.
  • Assuming an abstraction is free. Measure it, then decide.

Checkpoint

  1. Why does the pipeline take an open filehandle rather than a filename?
  2. How does a stage drop a record, and where does the count go?
  3. Why does counter return both a stage and a hash reference?
  4. What does open $fh, "<", \$string do and why is it useful in tests?
  5. Why must the read loop say defined?
  6. The object pipeline runs at a third the speed of the flat script. What did that buy, and what will you do about it?
Why are we using this language here?

Milestone 3 is Perl's strongest showing so far. The pattern library composes compiled regexes as values; one pattern with an optional group handles two log formats; named captures mean the parser reads like the data; and the whole thing is eighty lines including statistics and detection. In Python this is re.compile, a dict of patterns, match.groupdict(), and noticeably more scaffolding; in Go it is a struct, a regexp.MustCompile, and manual index juggling because Go's regexp has no named-capture convenience worth the name.

Milestone 4 is where Perl's age shows. bless gives you a class with no encapsulation, no attribute declarations and no type checking, so Strata::Record is thirty lines of hand-written accessors that Moo would generate and that Go or Ruby would give you outright. The three-times slowdown from wrapping each line in an object is also worse than it would be in a language with cheaper objects.

The right conclusion is not "Perl is bad at objects" but something more useful: Perl rewards you for keeping data flat and transformations dense, and charges you for elaborate structure. That is precisely the opposite of Ruby's incentives, and it is why the same architecture drawn on a whiteboard produces different code in each. Designing with the grain of the language, rather than importing an architecture wholesale, is most of what "knowing a language" means.

Repository state after Milestone 4

strata/
├── bin/
│   ├── strata            ingest subcommand, wires a pipeline together
│   ├── strata-scan       milestone 1: the filter
│   └── strata-report     milestone 2: one-pass reporting
├── lib/Strata/
│   ├── Pattern.pm        named, composable qr// building blocks
│   ├── Record.pm         fields + provenance + problems + entities
│   ├── Pipeline.pm       source -> parser -> stages, with stats
│   └── Parser/
│       └── Apache.pm     common and combined, lenient and strict
├── t/
│   ├── 20-parser-apache.t   good, imperfect, broken, statistics, detection
│   └── 30-pipeline.t        stages, dropping, provenance
└── share/fixtures/
    └── access.log        2,004 lines, four of them deliberately wrong
$ prove -l t/
All tests successful.  Files=2, Tests=9
$ git commit -am "milestone 4: a record model and a streaming pipeline"

The Mewlang cat, walking forwardInstalment 12 of the five-course curriculum. Next: Perl Milestones 5–8, where the code becomes a distributable module with a cpanfile, CSV, JSON and XML parsers arrive behind a sniffing dispatch table, the reader learns to survive gzip, mixed encodings and truncation, and entity extraction turns records into things you can correlate.

Continue