Skip to content
aviral gupta

// I5.5 · ~48 min · Intermediate

Build: a log-file analyser

You build a small log-file analyser: a regular expression reads each line, a Map counts the entries by level and by hour, and Intl writes the summary.

Lesson 5 of 5 in I5 Working with data

End of the module

You will be able to

  • Parse log lines with a regular expression with named groups, and skip lines that do not match
  • Count entries by level and by UTC hour with a Map
  • Write a summary with Intl that prints the same on every computer
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson I5.4. What does this print?

    console.log(new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1 }).format(1 / 6));
  2. Predict · Activity 2 of 7

    Predict before you read on. How many of these lines does the pattern accept?

    const LINE = /^(?<time>\S+) (?<level>INFO|WARN|ERROR) (?<message>.+)$/;
    const lines = [
      "2026-10-05T08:15:02Z INFO server started",
      "2026-10-05T08:16:00Z DEBUG cache warm",
      "broken",
    ];
    console.log(lines.filter((line) => LINE.test(line)).length);
  3. Practice · Activity 3 of 7

    Fill in the property of the match that holds the named groups.

    const { level } = LINE.exec(line).____;
    const { level } = LINE.exec(line).;
  4. Practice · Activity 4 of 7

    Put the steps of the analyser in order.

    1. 1.Drop the nulls
    2. 2.Count the entries in a Map, by level and by UTC hour
    3. 3.Split the log text into lines
    4. 4.Parse each line with the pattern; a bad line gives null
    5. 5.Write the counts with Intl
  5. Practice · Activity 5 of 7

    Counting by UTC hour. What does this print?

    const times = ["2026-10-05T08:15:02Z", "2026-10-05T08:59:59Z", "2026-10-05T09:00:00Z"];
    const byHour = new Map();
    for (const t of times) {
      const hour = new Date(t).getUTCHours();
      byHour.set(hour, (byHour.get(hour) ?? 0) + 1);
    }
    console.log([...byHour].join(" | "));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Someone added the g flag to the pattern. How many lines pass the filter?

    const LINE = /^\S+ (INFO|WARN|ERROR) .+$/g;
    const lines = ["t1 INFO a", "t2 INFO b", "t3 INFO c"];
    console.log(lines.filter((line) => LINE.test(line)).length);
  7. Apply · Activity 7 of 7

    Mini-task: extend the analyser. Given parsed entries, group them by level in a Map with the has/set pattern, so that each level holds the array of its entries. Then print every ERROR entry with its time, as hours:minutes:seconds in UTC from Intl.DateTimeFormat, followed by its message.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The finished analyser

The whole program in one file. LOG holds seven lines, one of them broken. parseLine() turns each line into an object or null, countBy() counts by level and by UTC hour, and formatSummary() writes the shares in German with Intl. The program prints the date range, the summary, a bar per hour and the different error messages, which a Set collects. The exercises build the three functions one at a time.

main.js

const LOG = `2026-10-05T08:15:02Z INFO server started on port 8080
2026-10-05T08:15:09Z WARN config file missing, using defaults
2026-10-05T09:02:44Z INFO user ada logged in
this line is broken
2026-10-05T09:47:13Z ERROR database timeout after 5000 ms
2026-10-05T10:05:00Z INFO user bo logged in
2026-10-05T10:31:27Z ERROR database timeout after 5000 ms`;

const LINE = /^(?<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) (?<level>INFO|WARN|ERROR) (?<message>.+)$/;

// One log line -> { time: Date, level, message }, or null if it does not match.
export function parseLine(line) {
  const match = LINE.exec(line);
  if (match === null) return null;
  const { time, level, message } = match.groups;
  return { time: new Date(time), level, message };
}

// Counts entries by the key that keyOf returns, in first-seen order.
export function countBy(entries, keyOf) {
  const counts = new Map();
  for (const entry of entries) {
    const key = keyOf(entry);
    counts.set(key, (counts.get(key) ?? 0) + 1);
  }
  return counts;
}

export const countByLevel = (entries) => countBy(entries, (entry) => entry.level);
export const countByHour = (entries) => countBy(entries, (entry) => entry.time.getUTCHours());

// counts: Map of level -> count. Returns the summary lines for a locale.
export function formatSummary(counts, locale) {
  const total = [...counts.values()].reduce((sum, n) => sum + n, 0);
  const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
  const list = new Intl.ListFormat(locale, { type: "conjunction" });
  const lines = [];
  for (const [level, count] of counts) {
    lines.push(`${level}: ${count} (${percent.format(count / total)})`);
  }
  lines.push(`levels: ${list.format([...counts.keys()])}`);
  return lines;
}

const lines = LOG.split("\n");
const entries = lines.map(parseLine).filter((entry) => entry !== null);
console.log(`read ${entries.length} of ${lines.length} lines`);

const day = new Intl.DateTimeFormat("de-DE", { dateStyle: "long", timeZone: "UTC" });
const time = new Intl.DateTimeFormat("de-DE", { timeStyle: "short", timeZone: "UTC" });
console.log("Log vom", day.format(entries[0].time), "bis", time.format(entries.at(-1).time), "UTC");

for (const line of formatSummary(countByLevel(entries), "de-DE")) console.log(line);

for (const [hour, count] of countByHour(entries)) {
  console.log(`${String(hour).padStart(2, "0")}:00 UTC ${"#".repeat(count)}`);
}

const errors = new Set(entries.filter((entry) => entry.level === "ERROR").map((entry) => entry.message));
console.log("different errors:", errors.size, "-", [...errors].join("; "));

Run it with

node main.js

Output

read 6 of 7 lines
Log vom 5. Oktober 2026 bis 10:31 UTC
INFO: 3 (50 %)
WARN: 1 (16,7 %)
ERROR: 2 (33,3 %)
levels: INFO, WARN und ERROR
08:00 UTC ##
09:00 UTC ##
10:00 UTC ##
different errors: 1 - database timeout after 5000 ms
  • The broken line was skipped: parseLine returned null and the filter dropped it.
  • The levels came out in the order they first appeared in the log, because a Map keeps insertion order.
  • Both errors had the same message, so the Set kept it once.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Exercises

Exercise 1 of 3

Parse a log line

parseLine(line) gets one line such as "2026-10-05T09:47:13Z ERROR database timeout after 5000 ms" and returns {time, level, message}: time as a Date, level as INFO, WARN or ERROR, and the whole rest as the message. A line that does not fit returns null. The starter splits on spaces, which cuts the message and never returns null. Use one anchored pattern with named groups.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Three named groups, separated by single spaces: (?<time>…) (?<level>…) (?<message>…). Anchor with ^ and $.

  2. Hint 2

    The level is an alternation: (?<level>INFO|WARN|ERROR). The message is everything else: (?<message>.+).

  3. Hint 3

    const match = LINE.exec(line); if (match === null) return null; then read match.groups and wrap time in new Date(…).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

const LINE = /^(?<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) (?<level>INFO|WARN|ERROR) (?<message>.+)$/;

// One log line -> { time: Date, level, message }, or null if it does not match.
export function parseLine(line) {
  const match = LINE.exec(line);
  if (match === null) return null;
  const { time, level, message } = match.groups;
  return { time: new Date(time), level, message };
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// One log line -> { time: Date, level, message }, or null if it does not match.
export function parseLine(line) {
  const [time, level, message] = line.split(" ");
  return { time: new Date(time), level, message };
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseLine} from './main.js';

test('The whole rest of the line is the message', () => {
  const entry = parseLine('2026-10-05T09:47:13Z ERROR database timeout after 5000 ms');
  assert.equal(entry?.level, 'ERROR', `the level is ${entry?.level}`);
  assert.equal(entry?.message, 'database timeout after 5000 ms', `the message is ${entry?.message}`);
});

test('The time is a Date at that UTC moment', () => {
  const entry = parseLine('2026-10-05T08:15:02Z INFO server started');
  assert.ok(entry?.time instanceof Date, 'time should be a Date');
  assert.equal(entry.time.toISOString(), '2026-10-05T08:15:02.000Z', `the time is ${entry.time.toISOString()}`);
});

test('A broken line gives null', () => {
  assert.equal(parseLine('this line is broken'), null, 'parseLine should return null for a broken line');
});

test('An unknown level gives null', () => {
  assert.equal(parseLine('2026-10-05T08:16:00Z DEBUG cache warm'), null, 'DEBUG is not one of the three levels');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 3

Count by level and by hour

countBy(entries, keyOf) should return a Map from each key that keyOf returns to how many entries have it, in first-seen order. countByLevel and countByHour use it, the second with the UTC hour of entry.time as a number. The starter’s countBy adds 1 to undefined, and countByHour uses the whole Date as the key. Fix both.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: the counts are NaN. What is counts.get(key) the first time a key comes up?

  2. Hint 2

    Start at 0: counts.set(key, (counts.get(key) ?? 0) + 1).

  3. Hint 3

    The key for an hour is entry.time.getUTCHours(), a number from 0 to 23.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Counts entries by the key that keyOf returns, in first-seen order.
export function countBy(entries, keyOf) {
  const counts = new Map();
  for (const entry of entries) {
    const key = keyOf(entry);
    counts.set(key, (counts.get(key) ?? 0) + 1);
  }
  return counts;
}

export const countByLevel = (entries) => countBy(entries, (entry) => entry.level);
export const countByHour = (entries) => countBy(entries, (entry) => entry.time.getUTCHours());
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Counts entries by the key that keyOf returns, in first-seen order.
export function countBy(entries, keyOf) {
  const counts = new Map();
  for (const entry of entries) {
    const key = keyOf(entry);
    counts.set(key, counts.get(key) + 1);
  }
  return counts;
}

export const countByLevel = (entries) => countBy(entries, (entry) => entry.level);
// TODO: count by the hour of entry.time, in UTC.
export const countByHour = (entries) => countBy(entries, (entry) => entry.time);

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {countByLevel, countByHour} from './main.js';

const at = (iso, level) => ({time: new Date(iso), level, message: 'x'});
const entries = [
  at('2026-10-05T08:15:02Z', 'INFO'),
  at('2026-10-05T08:40:00Z', 'ERROR'),
  at('2026-10-05T09:02:44Z', 'INFO'),
  at('2026-10-05T23:59:59Z', 'INFO')
];

test('Levels are counted in first-seen order', () => {
  const counts = countByLevel(entries);
  assert.deepEqual([...counts], [['INFO', 3], ['ERROR', 1]], `the counts are ${JSON.stringify([...counts])}`);
});

test('Hours are UTC hours as numbers', () => {
  const counts = countByHour(entries);
  assert.deepEqual([...counts], [[8, 2], [9, 1], [23, 1]], `the counts are ${JSON.stringify([...counts])}`);
});

test('No entries give an empty Map', () => {
  assert.equal(countByLevel([]).size, 0, 'countByLevel([]) should be empty');
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 3 of 3

Write the summary with Intl

formatSummary(counts, locale) gets a Map of level to count and returns an array of lines: one "LEVEL: count (share)" per level, with the share as a percentage with at most one decimal for the locale, and then "levels: " with the level names joined as a list for the locale. The starter builds the percentage and the list by hand, which is right for neither German nor English. Use Intl.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Run the tests: the German share has a point. Which Intl tool writes percentages for a locale?

  2. Hint 2

    new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(count / total)

  3. Hint 3

    new Intl.ListFormat(locale, { type: "conjunction" }).format([...counts.keys()])

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// counts: Map of level -> count. Returns the summary lines for a locale.
export function formatSummary(counts, locale) {
  const total = [...counts.values()].reduce((sum, n) => sum + n, 0);
  const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
  const list = new Intl.ListFormat(locale, { type: "conjunction" });
  const lines = [];
  for (const [level, count] of counts) {
    lines.push(`${level}: ${count} (${percent.format(count / total)})`);
  }
  lines.push(`levels: ${list.format([...counts.keys()])}`);
  return lines;
}
Run it on your computer

Install ECMAScript 2026 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// counts: Map of level -> count. Returns the summary lines for a locale.
export function formatSummary(counts, locale) {
  const total = [...counts.values()].reduce((sum, n) => sum + n, 0);
  const lines = [];
  for (const [level, count] of counts) {
    lines.push(`${level}: ${count} (${Math.round((count / total) * 1000) / 10}%)`);
  }
  lines.push(`levels: ${[...counts.keys()].join(", ")}`);
  return lines;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {formatSummary} from './main.js';

const counts = new Map([['INFO', 3], ['WARN', 1], ['ERROR', 2]]);

test('German shares have a comma and a no-break space before %', () => {
  const lines = formatSummary(counts, 'de-DE');
  assert.equal(lines[1], 'WARN: 1 (16,7\u00a0%)', `the WARN line is ${JSON.stringify(lines[1])}`);
});

test('English shares have a point and no space', () => {
  const lines = formatSummary(counts, 'en-US');
  assert.equal(lines[2], 'ERROR: 2 (33.3%)', `the ERROR line is ${JSON.stringify(lines[2])}`);
});

test('The levels are a list in the language of the locale', () => {
  const german = formatSummary(counts, 'de-DE').at(-1);
  const english = formatSummary(counts, 'en-US').at(-1);
  assert.equal(german, 'levels: INFO, WARN und ERROR', `the German list line is ${german}`);
  assert.equal(english, 'levels: INFO, WARN, and ERROR', `the English list line is ${english}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Forgetting to drop the lines that did not match

const LINE = /^(?<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) (?<level>INFO|WARN|ERROR) (?<message>.+)$/;

// One log line -> { time: Date, level, message }, or null if it does not match.
function parseLine(line) {
  const match = LINE.exec(line);
  if (match === null) return null;
  const { time, level, message } = match.groups;
  return { time: new Date(time), level, message };
}

const lines = ["2026-10-05T08:15:02Z INFO started", "broken"];
const levels = lines.map(parseLine).map((entry) => entry.level);
console.log(levels);

What Node.js prints

TypeError: Cannot read properties of null (reading 'level')

Why, and the fix

parseLine returns null for a line that does not match, and null has no properties. Filter the nulls out before you use the entries: lines.map(parseLine).filter((entry) => entry !== null). A log file almost always has a broken or empty line, such as the last line after the final newline.

matchAll over the whole log without g

const log = "INFO started\nERROR timeout";
const LINE = /^(?<level>INFO|WARN|ERROR) (?<message>.+)$/m;
for (const match of log.matchAll(LINE)) console.log(match.groups.level);

What Node.js prints

TypeError: String.prototype.matchAll called with a non-global RegExp argument

Why, and the fix

Reading every line of the text at once with matchAll is fine, but matchAll needs the g flag: /^…$/gm. The m flag makes ^ and $ match at each line, g makes matchAll find every match. For yes/no checks with test, leave g out.

Formatting the time string instead of a Date

const time = new Intl.DateTimeFormat("en-GB", { timeStyle: "short", timeZone: "UTC" });
const match = /^(?<time>\S+)/.exec("2026-10-05T08:15:02Z INFO started");
console.log(time.format(match.groups.time));

What Node.js prints

RangeError: Invalid time value

Why, and the fix

Groups are always strings. DateTimeFormat.format() wants a Date or a timestamp, and turns a string into a number, which gives NaN for an ISO string. Convert first: time.format(new Date(match.groups.time)). Doing it once in parseLine means the rest of the program never sees the string.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Parse, then forget the text

Each line looks like 2026-10-05T09:47:13Z ERROR database timeout. One anchored pattern with three named groups reads it: time, level (INFO|WARN|ERROR) and message, which is the rest of the line. parseLine() returns {time, level, message}, with time already turned into a Date, or null when exec finds no match. lines.map(parseLine).filter((entry) => entry !== null) keeps only the good lines, and from then on the program works with objects, not with text. Splitting on spaces would cut the message into words.

One counting function

countBy(entries, keyOf) is the counting pattern of lesson I5.1, made general: it calls keyOf for each entry and adds 1 to that key in a Map, starting from (counts.get(key) ?? 0). countByLevel passes entry.level, countByHour passes entry.time.getUTCHours(). The keys stay in the order they first appear, which for a log is the order of time. The hour must be a UTC hour: getHours() would group by the time zone of the computer that runs the analyser.

A summary for people

formatSummary(counts, locale) turns the Map into text. Intl.NumberFormat with style: "percent" and maximumFractionDigits: 1 writes each share, Intl.ListFormat joins the level names, and Intl.DateTimeFormat with timeZone: "UTC" writes the date of the log. The same function writes "33,3 %" for de-DE and "33.3%" for en-US. In German the space before % is a no-break space, so a test that expects that text writes it as \u00a0. Make each formatter once and reuse it in the loop.

Sources

Last reviewed October 5, 2026