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));// I5.5 · ~48 min · Intermediate
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
You will be able to
Warm-up · Activity 1 of 7
console.log(new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1 }).format(1 / 6));Predict · Activity 2 of 7
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);Practice · Activity 3 of 7
const { level } = LINE.exec(line).____;Practice · Activity 4 of 7
Practice · Activity 5 of 7
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(" | "));Brain teaser · Activity 6 of 7
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);Apply · Activity 7 of 7
Check your work against this list
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 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.jsOutput
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 msTab 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.
Exercise 1 of 3
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.
Three named groups, separated by single spaces: (?<time>…) (?<level>…) (?<message>…). Anchor with ^ and $.
The level is an alternation: (?<level>INFO|WARN|ERROR). The message is everything else: (?<message>.+).
const match = LINE.exec(line); if (match === null) return null; then read match.groups and wrap time in new Date(…).
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 };
}
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 2 of 3
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.
Run the tests: the counts are NaN. What is counts.get(key) the first time a key comes up?
Start at 0: counts.set(key, (counts.get(key) ?? 0) + 1).
The key for an hour is entry.time.getUTCHours(), a number from 0 to 23.
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());
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 3 of 3
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.
Run the tests: the German share has a point. Which Intl tool writes percentages for a locale?
new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(count / total)
new Intl.ListFormat(locale, { type: "conjunction" }).format([...counts.keys()])
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;
}
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsconst 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.
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 argumentWhy, 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.
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 valueWhy, 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
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.