Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Text & Patterns  ›  Capstone Project

Capstone — The Extractor

40 minTurning log lines in two formats into one typed record
You're building a piece ofQuarry — the extractor
This piece — parse_log(): Turns a line from either service into one typed record.
Scenario Tonight's yard mixes Quarry's own services with the legacy web gateway. The report needs one record per line, whichever service wrote it.
Your task
Build parse_log(line) returning {"ts", "level", "component", "message", "fields"} for a line in Quarry's format or the legacy gateway format, with ISO timestamps, full level names, durations as whole milliseconds under a key ending in _ms, and numbers as numbers; return None for anything else. Example: parse_log("[14/Sep/2026:08:15:02] E gateway: upstream timeout took=2s retries=3") → {"ts": "2026-09-14T08:15:02", "level": "ERROR", "component": "gateway", "message": "upstream timeout", "fields": {"took_ms": 2000, "retries": 3}}.

Capstone — The Extractor

In short: parse_log() turns a line from either of Quarry's two log formats into one typed record. The fixed part of each line — timestamp, level, component — is a head pattern, one per format, which is exactly what regex is good at. The variable part — an unknown number of key=value pairs mixed with plain words — is not: a repeated group can only keep its last repetition, so one giant pattern can't capture them all. A master- pattern scanner (named alternatives run with finditer) walks the tail one token at a time instead, and a small typed() helper turns each bare value into a number, a duration in milliseconds, or a string.

A log line split into a head (regex, fixed shape, feeding ts/level/component) and a tail (scanner, token by token, feeding message and fields) that merge into one record

Four pieces of Quarry's extractor exist. The gate decides whether a line is a log line. extract_fields names its parts. redact makes it safe to show. The guard keeps operator patterns from freezing the tab. What's missing is the piece every later stage has been waiting for: something that turns any log line in the yard into one typed record.

And the yard has a complication. Quarry's own services write 2026-09-14 08:15:02 WARN [auth] login failed user=maya took=312ms, but the old web gateway, which nobody is allowed to touch, writes [14/Sep/2026:08:15:02] E gateway: upstream timeout took=2s retries=3 — same information, different shape: a bracketed day-first timestamp with a month name, a one-letter level, a colon instead of brackets, durations in seconds. The report must not care which service wrote a line. This capstone builds parse_log(). It should take 35 to 50 minutes, and nothing in it is new — it is the whole section, used together.

The shape of the record

{
    "ts": "2026-09-14T08:15:02",        # ISO 8601, sorts correctly as text
    "level": "WARN",                    # always the full word
    "component": "auth",
    "message": "login failed",          # the words that aren't key=value
    "fields": {"user": "maya", "took_ms": 312},
}

Three rules make records from different services comparable: timestamps become YYYY-MM-DDTHH:MM:SS; levels are always DEBUG/INFO/WARN/ ERROR — the gateway's D/I/W/E are expanded; durations are whole milliseconds under a key ending in _ms. Lines matching neither format return None, exactly as extract_fields did.

First attempt: one enormous regex

The head of each format is fine — patterns like it all section. The trouble is the tail, a variable number of key=value pairs mixed with words. A repeated group looks like the answer:

What does this print?
import re
m = re.fullmatch(r"(?:(\w+)=(\S+) ?)+", "user=maya took=312ms")
print(m.groups())

You could keep going — match the tail with one pattern, findall pairs out of it, subtract the pairs to find the message, special-case quoted values — and each step adds another pattern with its own edge cases. The better design splits the work where the structure splits:

Drawing diagram…

A head pattern per format, which is a fixed shape, and a scanner for the tail, which is a sequence of tokens.

The heads

Each head pattern ends with a tail group that takes the rest of the line:

python

The gateway's month is a name — that's meaning, not shape — so the pattern only checks it looks like Sep, and a dictionary does the rest:

python

MONTHS.get returns None for a month that doesn't exist — the signal to reject the line rather than crash on it.

Scanning the tail

Lesson four built a scanner one character at a time. There's a middle road just as linear and much shorter: a master pattern of named alternatives, one per token kind, run with finditer. Each match is one token, and m.lastgroup names which alternative matched.

python

The scanner is a generator — one token at a time, no list built up front. Whitespace is never matched, so finditer skips it between tokens. The fourth item says whether the value was quoted: a quoted "3" is text someone deliberately wrote; a bare 3 is a number.

Alternation order is load-bearing. The engine tries alternatives left to right and takes the first that fits, so a general alternative placed first shadows every specific one after it:

broken — fix it

The quoted pair is being split into two words. Run it, work out why the pair alternative never wins, and fix it by changing only the order.

Watch the scanner walk the gateway tail from the drawing and sort each token into message or field:

step through it
1import re
2TOKEN = re.compile(r'(?P<pair>(?P<key>\w+)=(?:"(?P<quoted>[^"]*)"|(?P<bare>\S+)))|(?P<word>\S+)')
3tail = "upstream timeout took=2s retries=3"
4words, raw_fields = [], {}
5for m in TOKEN.finditer(tail):
6 if m.lastgroup == "pair":
7 raw_fields[m["key"]] = m["bare"]
8 else:
9 words.append(m["word"])
10message = " ".join(words)
11print(message, raw_fields)

Typing the values

The scanner produces strings; the record wants types. Each bare value gets one pass, most specific check first: a duration — \d+(?:\.\d+)?(ms|s) — becomes whole milliseconds under a _ms key; a number becomes an int or float; anything else stays a string. Each check is a fullmatch on one short token — anchored, nothing to backtrack into.

Putting it together

The finished function reads top to bottom, like the flowchart: try the Quarry head, then the gateway head, normalise the timestamp and level, scan the tail, type the values, assemble the record. Keep each step a small helper — head(), tokens(), typed() — so parse_log itself stays short. When a third format arrives, it's one more head pattern and one more branch in head(); the scanner and the typing never change.

Before you check, test the edges yourself: a line with no fields, a quoted value with spaces, a gateway line with an impossible month, a stack trace.

Quick practice

What does this print?
import re
DURATION = re.compile(r"(\d+(?:\.\d+)?)(ms|s)")
print([bool(DURATION.fullmatch(v)) for v in ["2s", "1.5s", "312ms", "5sec", "s"]])
What does this print?
MONTHS = {name: i for i, name in enumerate(
    ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
     "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], start=1)}
print(MONTHS.get("Sec"), MONTHS.get("Dec"))

🎯 Your turn

Write parse_log(line) returning the typed record described above, or None if the line matches neither format.

  • parse_log("2026-09-14 08:15:02 WARN [auth] login failed user=maya took=312ms") → {"ts": "2026-09-14T08:15:02", "level": "WARN", "component": "auth", "message": "login failed", "fields": {"user": "maya", "took_ms": 312}}
  • parse_log("[14/Sep/2026:08:15:02] E gateway: upstream timeout took=2s retries=3") → {"ts": "2026-09-14T08:15:02", "level": "ERROR", "component": "gateway", "message": "upstream timeout", "fields": {"took_ms": 2000, "retries": 3}}
  • parse_log("Traceback (most recent call last):") → None

Hint — head patterns for the fixed part, the master-pattern scanner for the tail, and a small typed() helper for the values. Quoted values stay strings.

Then press ▶ Run and hit ✓ Check. Green = Quarry's extractor is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upThe gateway's timestamp, in ISO

Write iso_gateway_time(stamp) turning a gateway stamp like "14/Sep/2026:08:15:02" into "2026-09-14T08:15:02". Return None if the stamp has the wrong shape or the month isn't a real three-letter month name.

regex-groupsnamed-groupdictionaries
DrillFrom text to a number

Write typed_value(raw): return an int for a run of digits, a float for digits with one decimal point and digits after it, and the original string for anything else.

regexre.fullmatchtype-casting
BuildScan a tail into tokens

Write tail_tokens(tail) returning a list of [kind, key, value] tokens using a master pattern and finditer. A pair is key=value or key="quoted value" → ["pair", key, value] (quotes removed, value left as a string). Any other run of non-space characters → ["word", None, text].

tokenisingmaster-patternlastgroup
BossMessage and typed fields

Write split_tail(tail) returning {"message": ..., "fields": {...}}. The message is every non-pair word joined by single spaces. Fields are typed: a bare duration (312ms, 2s, 1.5s) is stored under key + "_ms" as whole milliseconds; a bare number becomes an int or float; quoted values and other words stay strings.

tokenisingregex-groupsdictionaries
CapstoneSummarise a whole yard

Write summarise(lines) returning {"parsed": n, "skipped": m, "by_level": {...}, "slowest": component} over a list of lines in either format. by_level counts parsed lines per (full) level. slowest is the component of the parsed line with the largest took_ms, or None if no line has one; on a tie, the earliest line wins. Build your own parse_log inside the solution.

tokenisingregex-groupsregexdictionaries
ExplainWhy a head pattern plus a scanner

The extractor uses a regex for the head of each line and a token scanner for the tail, instead of one pattern for the whole line. Explain why, referring to at least one concrete thing a single pattern cannot do, and say what happens to the design when a third log format appears.

parse_log.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
parse_log(line) → dictOne typed record for a line in either format, or None.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.