1 The Problem
We want a log analyser: read a server log file, count how many entries are errors versus normal, find which hour had the most traffic, and list the most frequent error messages. It teaches parsing semi-structured text at scale and aggregating it into useful insight — a daily task in operations.
2 How to Think About It
Think about turning lines into counts, before any code:
3 The Build — explained part by part
Here is the complete analyser. Read each part’s note below — you should understand the whole thing from the notes alone.
import * as fs from "node:fs";
interface Entry {
hour: string;
level: string;
message: string;
}
export interface Report {
total: number;
errors: number;
busiestHour: [string, number] | null;
topErrors: [string, number][];
}
// bump increments a key in a Map-based counter — TypeScript has no built-in
// Counter like Python's collections.Counter, so this tiny helper plays the
// same role.
function bump(counter: Map<string, number>, key: string): void {
counter.set(key, (counter.get(key) ?? 0) + 1);
}
function topN(counter: Map<string, number>, n: number): [string, number][] {
return [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, n);
}
export function parseLine(line: string): Entry | null {
// Expected format: "2026-06-24 14:30:00 ERROR Database timeout"
const parts = line.split(" ");
if (parts.length < 4) return null;
const [, time, level, ...rest] = parts;
return { hour: time.slice(0, 2), level, message: rest.join(" ").trim() };
}
export function analyse(lines: string[]): Report {
const levels = new Map<string, number>();
const hours = new Map<string, number>();
const errors = new Map<string, number>();
for (const line of lines) {
const entry = parseLine(line);
if (!entry) continue;
bump(levels, entry.level);
bump(hours, entry.hour);
if (entry.level === "ERROR") bump(errors, entry.message);
}
const total = [...levels.values()].reduce((a, b) => a + b, 0);
const busiest = topN(hours, 1);
return {
total,
errors: levels.get("ERROR") ?? 0,
busiestHour: busiest.length ? busiest[0] : null,
topErrors: topN(errors, 3),
};
}
function main(): void {
const lines = fs.readFileSync("server.log", "utf-8").split(/\r?\n/).filter(Boolean);
const report = analyse(lines);
console.log(`Total entries: ${report.total}`);
console.log(`Errors: ${report.errors}`);
console.log(`Busiest hour: ${report.busiestHour ? report.busiestHour.join(", ") : "null"}`);
console.log(`Top errors: ${JSON.stringify(report.topErrors)}`);
}
if (require.main === module) {
main();
}Entry always has an hour, a level, and a message; a Report always has all four summary fields. Mistype a field name anywhere and the compiler catches it.function bump(counter: Map<string, number>, key: string) — TypeScript has no built-in
Counter like Python’s collections.Counter, so this one small helper plays the same role: look up the current count (or 0 if it is new) and add one, using a Map rather than a plain object because the keys are not known ahead of time.function topN(counter, n): [string, number][] —
[string, number] is a tuple type: an array that is always exactly a string followed by a number, used here for a “key and its count” pair, matching the shape of Python’s Counter.most_common().parts.split(" ") then destructuring
const [, time, level, ...rest] = parts — skips the date (the leading comma), grabs time and level by position, and collects everything else into rest with the rest operator, rejoining it with rest.join(" ") to rebuild the message — equivalent to Python’s line.split(" ", 3).
{} object as a counter.Map<string, number> is the right tool when keys are only known at runtime — it also avoids subtle bugs from inherited object properties like toString colliding with a real key.topErrors as string[][] instead of [string, number][].string[][] says “an array of arrays of strings” — it would silently accept ["a", "b", "c"] as one entry. The tuple type [string, number] pins down exactly two elements of exactly those types.parts.length < 4 and return null for malformed lines, the same guard the Python version uses, so one bad line does not crash the whole report.4 Test & Prove Each Part
How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseLine, analyse } from "./log-analyser";
const LINES = [
"2026-06-24 14:30:00 ERROR Database timeout",
"2026-06-24 14:45:00 INFO Request served",
"2026-06-24 15:00:00 ERROR Disk full",
];
test("a line parses into its parts", () => {
const entry = parseLine(LINES[0]);
assert.equal(entry?.hour, "14");
assert.equal(entry?.level, "ERROR");
});
test("two errors are counted", () => {
assert.equal(analyse(LINES).errors, 2);
});
test("hour 14 has the most entries", () => {
assert.equal(analyse(LINES).busiestHour?.[0], "14");
});Compile with npx tsc then run node --test loganalyse.test.js. parseLine and analyse take plain strings and arrays, so the tests check known log lines directly, without touching a real server.log file.
5 The Interface
What it expects
2026-06-24 14:30:00 ERROR Database timeoutWhat it returns
Total: 6 Errors: 3
Busiest hour: ("14", 3)
Top errors: [("Database timeout", 2), ...]6 Run It & Automate It
Save the code as loganalyse.ts, compile with npx tsc, and run with node loganalyse.js against a real server.log file in the same folder — or run it directly with npx tsx loganalyse.ts.
npx tsc loganalyse.ts && node loganalyse.jsReads server.log in the current folder and prints the summary.
A CI tool like Jenkins runs the type-checker and tests automatically whenever the code changes — every line below has a plain explanation.
Total entries: 3
Errors: 2
Busiest hour: 14, 2
Top errors: [["Database timeout",1],["Disk full",1]]ENOENT: no such file or directory, open 'server.log'server.log in the current working directory. Create one (or copy a real one) before running it.DATE TIME LEVEL MESSAGE with single spaces between the first three fields — parseLine relies on that exact shape.// Jenkinsfile — runs the type-checker and tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Node') {
steps {
sh 'node --version' // confirm Node is installed
sh 'npm install -D typescript @types/node' // zero runtime deps — just the compiler and its Node types
}
}
stage('Type-check and test') {
steps {
sh 'npx tsc --noEmit' // catch type errors before anything runs
sh 'npx tsc' // compile to plain JavaScript
sh 'node --test loganalyse.test.js' // Node's built-in test runner, no extra install needed
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
You have a working log analyser. Extend it:
- Stream instead of loading the whole file. Use
readline.createInterfaceover a file stream for logs too large to fit in memory. (Teaches: streaming line-by-line processing.) - Support more levels. Break out WARN counts alongside ERROR. (Teaches: extending the
Reportinterface.) - Parse real timestamps. Use the
Dateobject instead of just the hour substring. (Teaches:Dateparsing and its pitfalls.) - Output JSON. Print
JSON.stringify(report, null, 2)for a machine-readable report. (Teaches: pretty-printing JSON.)