Learn / Testing and observability
LESSON 74 OF 84

Testing and observability Core

Structured logging

Replace console.log with structured log entries that a program can search. Levels, child loggers, request ids, request and error logging, secret redaction and JSON logs for production with @zudojs/logger.

  • 40 min to read and try
  • You need: The Task API project and the testing lesson
  • You build: A Task API logger that prints readable lines in development, JSON in production, and tags every request with an id
Test yourself

Why structured logs

A log is the diary of a running program: one line for each thing worth remembering. When a user says "my task disappeared yesterday at 14:05", the logs are often the only way to find out what happened.

So far you logged with console.log and a sentence. That is easy to write and hard to use later. Compare two ways to write the same three events:

why.js
const sentences = [
  "Task 7 created by ada",
  "ada completed task 7",
  "Task 9 created by linus",
];

const records = [
  { level: "info", message: "task created", taskId: 7, userId: "ada" },
  { level: "info", message: "task completed", taskId: 7, userId: "ada" },
  { level: "info", message: "task created", taskId: 9, userId: "linus" },
];

console.log(sentences.filter((line) => line.includes("ada")));
console.log(records.filter((r) => r.userId === "ada").map((r) => r.message));
console.log(records.filter((r) => r.taskId === 7).length, "events for task 7");
Output of node why.js and of the browser terminal
[ 'Task 7 created by ada', 'ada completed task 7' ]
[ 'task created', 'task completed' ]
2 events for task 7

Searching sentences means guessing how each one was worded. The second list is a structured log: every entry is a record with named fields. A short, fixed message says what happened, and the details go into separate fields, called metadata. Log tools can then filter by userId, count by message or chart a durationMs, the same way you query a database table.

Your first logger

@zudojs/logger is already in the Task API: the CLI installed it, and src/app.ts creates the logger that the runtime uses. (npm ls @zudojs/logger shows the version, 1.4 or later.) In a new project you would run npm install @zudojs/logger. Create a logger and write one entry:

first.ts
import { createLogger } from "@zudojs/logger";

const logger = createLogger({ name: "task-api" });

logger.info("task created", { taskId: 7, userId: "ada" });
Output of npx tsx first.ts and of the browser terminal
2026-09-23T17:13:02.238Z [INFO] [task-api] task created taskId=7 userId=ada

These are the lines you saw when you started the Task API with npm run dev. When you configure nothing, the logger uses a text formatter and prints each entry to the console as one readable line. Two options decide what you see and where it goes:

  • A formatter turns an entry into text: a readable line, or a line of JSON.
  • A transport sends the entry somewhere: the terminal, a file, a log service. It can be a plain function. It receives the entry: entry.message is your raw message, and entry.formatted is the formatter's finished line. Its type says it may be missing, so write entry.formatted ?? entry.message.
format.ts
import { createLogger, createTextLoggerFormatter } from "@zudojs/logger";

const logger = createLogger({
  name: "task-api",
  formatter: createTextLoggerFormatter(),
  transports: [(entry) => console.log(entry.formatted ?? entry.message, "| raw:", entry.message)],
});

logger.info("task created", { taskId: 7, userId: "ada", title: "Buy milk" });
logger.warn("slow query", { durationMs: 840 });
Output of npx tsx format.ts and of the browser terminal
2026-09-23T17:20:16.059Z [INFO] [task-api] task created taskId=7 userId=ada title="Buy milk" | raw: task created
2026-09-23T17:20:16.064Z [WARN] [task-api] slow query durationMs=840 | raw: slow query

One line per entry: time, level, logger name, message, then the metadata as key=value pairs. A value with a space gets quotes, so title="Buy milk" stays one field. After the |, the raw message is still just task created: a transport that sends entries to a log service can send the fields, not the finished line. To keep the rest of this page free of changing timestamps, the next examples pass createTextLoggerFormatter({ includeTimestamp: false }). Always keep the time in a real application.

Levels

Every entry has a level that says how serious it is. There are six, from most to least serious:

LevelUse it forTask API example
fatalThe process cannot continue.The database settings are missing at start-up.
errorSomething failed that should have worked.Saving a task threw.
warnUnusual, but handled.A query took 2 seconds. A login failed.
infoNormal, important events.Server started. Task created.
debugDetails for a developer who is hunting a bug.The SQL that ran and its parameters.
traceEven more detail, step by step.Every cache lookup.

A logger has a threshold level, info by default. It writes entries at that level or more serious, and silently drops the rest:

levels.ts
import { createLogger, createTextLoggerFormatter } from "@zudojs/logger";

const logger = createLogger({
  name: "task-api",
  formatter: createTextLoggerFormatter({ includeTimestamp: false }),
  transports: [(entry) => console.log(entry.formatted ?? entry.message)],
});

logger.debug("cache miss", { key: "tasks:ada" });
logger.info("task created", { taskId: 7 });

logger.setLevel("debug");
logger.debug("cache miss", { key: "tasks:ada" });

logger.setLevel("warn");
logger.info("task created", { taskId: 8 });
logger.error("could not save task", { taskId: 8 });
Output of npx tsx levels.ts and of the browser terminal
[INFO] [task-api] task created taskId=7
[DEBUG] [task-api] cache miss key=tasks:ada
[ERROR] [task-api] could not save task taskId=8

The first debug entry and the last info entry were dropped. That is the point of levels: you can leave debug lines in your code, and switch them on only while you look for a bug. You can name a level as a string ("debug", in any case) or with the LoggerLevel enum (LoggerLevel.DEBUG). An unknown name throws, so a typo cannot switch your logs off.

The level belongs in configuration, not in code. At the end of this lesson you read it from a LOG_LEVEL environment variable.

Metadata and child loggers

Some fields belong on every entry of one part of the app: which module wrote it, which version is running. A child logger is a copy of a logger with a new name and extra metadata. It shares the parent's formatter, transports and level:

child.ts
import { createLogger, createTextLoggerFormatter } from "@zudojs/logger";

const logger = createLogger({
  name: "task-api",
  metadata: { version: "0.1.0" },
  formatter: createTextLoggerFormatter({ includeTimestamp: false }),
  transports: [(entry) => console.log(entry.formatted ?? entry.message)],
});

const dbLogger = logger.child({ name: "task-api.db", metadata: { component: "db" } });
const mailLogger = logger.child({ name: "task-api.mail", metadata: { component: "mail" } });

logger.info("server started", { port: 3000 });
dbLogger.info("connected", { pool: 10 });
mailLogger.warn("mail server slow", { durationMs: 2300 });
Output of npx tsx child.ts and of the browser terminal
[INFO] [task-api] server started version=0.1.0 port=3000
[INFO] [task-api.db] connected version=0.1.0 component=db pool=10
[WARN] [task-api.mail] mail server slow version=0.1.0 component=mail durationMs=2300

Give each module its own child logger once, when the app starts, and pass it in, just like the event bus in the events lesson. Do not call createLogger inside a service: the placeholder AppService the CLI generated did that (the runtime lesson replaced it), and such a logger ignores every setting you give the main one.

Request ids and context

The Task API serves many users at once, so the log lines of different requests are mixed together. To follow one request, give it a request id, a unique string, and put it on every line that request writes. Then one search for that id shows the whole story. When several services pass the same id along, it is called a correlation id: you will use one in the observability lesson.

logger.withContext(context) returns a logger that adds a context to every entry. createLoggerContext knows the usual identifiers (requestId, correlationId, traceId, userId, tenantId and more) and takes extra fields in metadata:

context.ts
import { createLogger, createLoggerContext, createTextLoggerFormatter } from "@zudojs/logger";

const logger = createLogger({
  name: "task-api",
  formatter: createTextLoggerFormatter({ includeTimestamp: false }),
  transports: [(entry) => console.log(entry.formatted ?? entry.message)],
});

const requestLog = logger.withContext(
  createLoggerContext({ requestId: "req-41", userId: "ada", metadata: { route: "POST /tasks" } }),
);

requestLog.info("task created", { taskId: 7 });
requestLog.child({ name: "task-api.db" }).info("row inserted", { table: "tasks" });
logger.info("no request here");
Output of npx tsx context.ts and of the browser terminal
[INFO] [task-api] task created requestId=req-41 userId=ada route="POST /tasks" taskId=7
[INFO] [task-api.db] row inserted requestId=req-41 userId=ada route="POST /tasks" table=tasks
[INFO] [task-api] no request here

The context also travels into child loggers of the scoped logger. The original logger is unchanged.

Log every request

Now make it automatic. This wrapper goes around the Task API's request handler. For every request it creates an id, builds a scoped logger, and writes one line when the request is done: method, path, status and how long it took. The handler receives the scoped logger, so everything it logs carries the same id:

request-logging.tsNode.js only
import { randomUUID } from "node:crypto";
import { createHttpServer, createNodeHttpAdapter, createResponseContext } from "@zudojs/http";
import type { HttpRequestContext, HttpResponseContext } from "@zudojs/http";
import { createLogger, createLoggerContext, createTextLoggerFormatter } from "@zudojs/logger";
import type { Logger } from "@zudojs/logger";

const logger = createLogger({
  name: "task-api",
  formatter: createTextLoggerFormatter({ includeTimestamp: false }),
  transports: [(entry) => console.log(entry.formatted ?? entry.message)],
});

type Handler = (request: HttpRequestContext, log: Logger) => Promise<HttpResponseContext>;

function withRequestLogging(handler: Handler) {
  return async (request: HttpRequestContext): Promise<HttpResponseContext> => {
    const log = logger.withContext(createLoggerContext({ requestId: randomUUID() }));
    const started = performance.now();
    const response = await handler(request, log);
    const duration = `${Math.round(performance.now() - started)}ms`;
    log.info("request done", { method: request.method, path: request.path, status: response.status, duration });
    return response;
  };
}

const server = createHttpServer({
  adapter: createNodeHttpAdapter({ port: 0 }),
  handler: withRequestLogging(async (request, log) => {
    if (request.path === "/tasks") {
      log.info("listing tasks", { count: 1 });
      return createResponseContext().json([{ id: 7, title: "Buy milk" }]);
    }
    return createResponseContext({ status: 404 }).json({ error: "Not Found" });
  }),
});

await server.start();
const base = `http://localhost:${server.address?.port}`;
for (const path of ["/tasks", "/nope"]) {
  const response = await fetch(base + path);
  console.log("client got", response.status);
}
await server.stop();
Output of npx tsx request-logging.ts
[INFO] [task-api] listing tasks requestId=090ee428-38fe-43d3-9eff-5877a964b344 count=1
[INFO] [task-api] request done requestId=090ee428-38fe-43d3-9eff-5877a964b344 method=GET path=/tasks status=200 duration=3ms
client got 200
[INFO] [task-api] request done requestId=54a04b7e-67a2-43e7-a09a-c0e55f828fe0 method=GET path=/nope status=404 duration=0ms
client got 404

randomUUID() creates an id that is unique in practice. The two lines of the first request share one id, so you can tell them apart from the second request's line. The server starts on port 0, which means "any free port", and the example stops it after two requests, so it can run to the end.

A REQUEST ID FROM THE CLIENT IS ONLY A LABEL

Some clients send their own x-request-id header, and by default @zudojs/http reuses a valid one as request.id (see the middleware lesson). That is useful behind your own proxy or gateway, which sets the id for you. But on a public API, anyone can pick the id and mix fake lines into another request's story. There, create the adapter with createNodeHttpAdapter({ trustRequestId: false }) so every request gets an id your server made, and if you want to keep the client's value, log it as a separate field. Never treat any request id as proof of anything.

Logging errors

When something throws, log the error object, not just its message: the object carries its name and the stack trace, the list of function calls that led to the error. logError(logger, error, message, metadata) writes an error entry with the error attached:

log-error.ts
import { BaseError, NotFoundError } from "@zudojs/errors";
import { createLogger, createTextLoggerFormatter, logError } from "@zudojs/logger";
import type { Logger } from "@zudojs/logger";

const logger = createLogger({
  name: "task-api",
  formatter: createTextLoggerFormatter({ includeTimestamp: false, includeStackTrace: false }),
  transports: [(entry) => console.log(entry.formatted ?? entry.message)],
});

function logFailure(log: Logger, error: unknown, route: string): number {
  const status = error instanceof BaseError ? error.statusCode : 500;
  if (status < 500) {
    log.warn("request rejected", { route, status, reason: error instanceof Error ? error.message : "unknown" });
  } else {
    logError(log, error instanceof Error ? error : new Error(String(error)), "request failed", { route, status });
  }
  return status;
}

logFailure(logger, new NotFoundError("Task 9 not found"), "GET /tasks/9");
logFailure(logger, new Error("connect ECONNREFUSED 127.0.0.1:5432"), "POST /tasks");
Output of npx tsx log-error.ts and of the browser terminal
[WARN] [task-api] request rejected route="GET /tasks/9" status=404 reason="Task 9 not found"
[ERROR] [task-api] request failed route="POST /tasks" status=500 error={"name":"Error","message":"connect ECONNREFUSED 127.0.0.1:5432"}

The error entry ends with the error's name and message. includeStackTrace: false left out the stack trace to keep this page short. Save the example on your computer, remove that option, and run it. Now the error entry continues with the stack trace, one call per line:

Terminal on your computer
npx tsx log-error.ts
[WARN] [task-api] request rejected route="GET /tasks/9" status=404 reason="Task 9 not found"
[ERROR] [task-api] request failed route="POST /tasks" status=500
Error: connect ECONNREFUSED 127.0.0.1:5432
    at <anonymous> (~/task-api/log-error.ts:22:20)
    at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
    at async node:internal/modules/esm/loader:643:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

The first frame points at line 22 of your file, where the error was created. That is where you start looking.

Two rules make error logs useful:

  • 4xx is not an error of your server. A missing task or a bad request body is the client's mistake. Log it as warn (or info), without a stack trace. Keep error for 5xx, the failures you must fix. If every 404 is an error, the real errors drown.
  • Log an error once. Do not log it in the repository, again in the service, and again in the handler. Let it travel up, and log it in one place: the request wrapper. There you have the request id too.

The error details go to the log, never to the client. The client gets the status and a safe message, as you saw in the errors lesson.

Keep secrets out of logs

Logs are read by many people and kept for a long time, often in another company's service. A password or token that lands in a log has leaked. The logger redacts (hides) metadata fields whose name looks secret, before any formatter or transport sees them:

redaction.ts
import { createLogger, createTextLoggerFormatter } from "@zudojs/logger";

const logger = createLogger({
  name: "task-api",
  redact: { keys: ["email"] },
  formatter: createTextLoggerFormatter({ includeTimestamp: false }),
  transports: [(entry) => console.log(entry.formatted ?? entry.message)],
});

logger.info("login", {
  userId: "ada",
  email: "ada@example.com",
  password: "hunter2",
  headers: { authorization: "Bearer abc.def", "x-api-key": "k-123", accept: "application/json" },
  sessionId: "s-77",
});

const password = "hunter2";
logger.warn(`BAD: login failed with password ${password}`);

logger.info("task created", { title: "Buy milk\n[ERROR] [task-api] fake entry" });
Output of npx tsx redaction.ts and of the browser terminal
[INFO] [task-api] login userId=ada email=[REDACTED] password=[REDACTED] headers={"authorization":"[REDACTED]","x-api-key":"[REDACTED]","accept":"application/json"} sessionId=[REDACTED]
[WARN] [task-api] BAD: login failed with password hunter2
[INFO] [task-api] task created title="Buy milk\n[ERROR] [task-api] fake entry"
  • Redaction is on by default. It knows names like password, token, authorization, cookie, session, apiKey and x-api-key, also inside nested objects. redact.keys adds your own names, here email, which is personal data.
  • It works on field names only. The second line leaked the password, because it was glued into the message text. Never build messages from outside values. Put values in metadata, and leave secrets out completely.
  • The last title contains a newline, an attempt to fake a second log line. This is called log injection. The formatter escaped it to \n, so it stays inside one entry.

Production: JSON on standard output

In production nobody reads the terminal. The platform that runs your app (Docker, Kubernetes, a cloud service) collects everything the process writes to standard output and ships it to a log store. The usual rules:

  • Write one JSON object per line. Log stores read JSON fields without guessing.
  • Write to standard output, not to files. Collecting and rotating files is the platform's job.
  • Before the process exits, flush: wait until every entry has been written.

createJsonLoggerFormatter() turns each entry into one line of JSON, and a transport that calls process.stdout.write sends it to standard output. await logger.flush() waits for pending writes. The next section puts these together.

Put it together: the Task API logger

Put the choice in one place. The generated project has a src/loggers folder for exactly this (its index.ts is empty). Create a file there:

src/loggers/app.logger.tsNode.js only
import {
  createJsonLoggerFormatter,
  createLogger,
  createTextLoggerFormatter,
  LoggerLevel,
  loggerLevelFromName,
} from "@zudojs/logger";
import type { Logger } from "@zudojs/logger";

export interface LoggerEnv {
  readonly NODE_ENV?: string;
  readonly LOG_LEVEL?: string;
}

export function createAppLogger(env: LoggerEnv = process.env): Logger {
  const production = env.NODE_ENV === "production";
  const level = env.LOG_LEVEL?.trim() ? loggerLevelFromName(env.LOG_LEVEL.trim()) : LoggerLevel.INFO;
  return createLogger({
    name: "task-api",
    level,
    environment: production ? "production" : "development",
    formatter: production ? createJsonLoggerFormatter() : createTextLoggerFormatter(),
    transports: [(entry) => {
      process.stdout.write((entry.formatted ?? entry.message) + "\n");
    }],
  });
}
src/check-logger.tsNode.js only
import { InvalidLoggerLevelError } from "@zudojs/logger";
import { createAppLogger } from "./loggers/app.logger.js";

const dev = createAppLogger({ LOG_LEVEL: "debug" });
dev.debug("dev logger ready", { level: dev.level });

const prod = createAppLogger({ NODE_ENV: "production" });
prod.debug("hidden: production defaults to info");
prod.info("prod logger ready", { level: prod.level });
await Promise.all([dev.flush(), prod.flush()]);

try {
  createAppLogger({ LOG_LEVEL: "loud" });
} catch (error) {
  if (error instanceof InvalidLoggerLevelError) console.log(error.name, "-", error.message);
}
Output of npx tsx src/check-logger.ts
2026-09-23T13:52:19.445Z [DEBUG] [task-api] dev logger ready level=4
{"id":"log:4821dea8-8c95-47ad-aa4c-499421a11562","level":3,"levelName":"info","message":"prod logger ready","metadata":{"level":3},"logger":"task-api","timestamp":"2026-09-23T13:52:19.448Z","timestampMs":1790171539448,"environment":"production"}
InvalidLoggerLevelError - Invalid logger level: loud.

The production entry has a unique id, the level as a number and as a name, the message, the metadata as a real JSON object, and the time twice: as text and as milliseconds. Redaction runs in both formats. An error entry adds an error object with name, message and stack.

LOG_LEVEL=debug and LOG_LEVEL=DEBUG both work. An unknown LOG_LEVEL throws at start-up, where you notice it at once, instead of silently logging nothing for weeks. The function takes the environment as a parameter, with process.env as the default. That makes it easy to test, as you did in the testing lesson: pass a plain object instead of changing real environment variables.

Now use it in src/app.ts. Replace the createLogger line and its import:

src/app.ts (part)Node.js only
import { createAppLogger } from "./loggers/app.logger.js";

// inside createApp():
  const logger = createAppLogger();

createAppLogger() reads process.env. By the time createApp runs, src/server.ts has already called loadConfig(), which loads .env, so a LOG_LEVEL=debug line in .env works too.

Start the server in development mode, then in production mode. NODE_ENV=production before a command sets that variable for that one command. Stop each one with Ctrl + C:

Terminal on your computer
npx tsx src/server.ts
2026-09-23T20:03:47.496Z [INFO] [task-api] store connected
2026-09-23T20:03:47.498Z [INFO] [task-api] tasks ready, 1 in store
2026-09-23T20:03:47.498Z [INFO] [task-api] All modules initialized. modules=["integrations","store","tasks"] durationMs=4

Listening on http://127.0.0.1:3000
NODE_ENV=production npx tsx src/server.ts
{"id":"log:c81e9cbd-091f-4b26-9789-4410027f89c4","level":3,"levelName":"info","message":"store connected","metadata":{},"logger":"task-api","timestamp":"2026-09-23T20:03:49.410Z","timestampMs":1790193829410,"environment":"production"}
{"id":"log:1336da80-eec8-4c3d-a748-4f978aef5a2e","level":3,"levelName":"info","message":"tasks ready, 1 in store","metadata":{},"logger":"task-api","timestamp":"2026-09-23T20:03:49.412Z","timestampMs":1790193829412,"environment":"production"}
{"id":"log:12c7c3d4-d344-4b38-988a-22bf34a64446","level":3,"levelName":"info","message":"All modules initialized.","metadata":{"modules":["integrations","store","tasks"],"durationMs":5},"logger":"task-api","timestamp":"2026-09-23T20:03:49.412Z","timestampMs":1790193829412,"environment":"production"}

Listening on http://0.0.0.0:3000

The runtime's own lines and your modules' lines now use your format: readable text in development, one JSON object per line in production. So do the bug reports from the errors lesson, because src/server.ts builds them with logBugs(runtime.context.logger), the same logger. One line is still plain text: Listening on …, a console.log in src/server.ts. Log collectors keep a line that is not JSON as plain text, so it does no harm; to make it JSON too, write it with runtime.context.logger.info("listening", { host: config.host, port: config.port }).

TIP

On Windows PowerShell, set the variable first: $env:NODE_ENV="production"; npx tsx src/server.ts.

Practice

TRY IT YOURSELF

Count entries per level

A transport can do more than print. Write one that counts entries per level name in a Map (use entry.levelName). Log two info entries, one warn and one debug with the default level, then print the map.

Show a solution
count-levels.ts
import { createLogger } from "@zudojs/logger";

const counts = new Map<string, number>();
const logger = createLogger({
  name: "task-api",
  transports: [(entry) => {
    counts.set(entry.levelName, (counts.get(entry.levelName) ?? 0) + 1);
  }],
});

logger.info("task created");
logger.info("task completed");
logger.warn("slow query");
logger.debug("cache miss");
console.log(counts);
Output of npx tsx count-levels.ts and of the browser terminal
Map(2) { 'info' => 2, 'warn' => 1 }

The debug entry never reached the transport: the level check happens first. A transport like this is also handy in tests, to check what your code logged.

TRY IT YOURSELF

Find the leaks

Which of these lines leak a secret into the logs, and how do you fix them? (a) log.info("login", { user, password }) (b) log.info(`token issued: ${token}`) (c) log.debug("request", { headers: request.headers }) (d) log.info("user", { profile: { name, creditCard, phone } })

Show a solution

(a) is safe: password is redacted by name. (b) leaks: the token is inside the message text, which is never redacted. Log "token issued" with a harmless field like { userId } instead. (c) is safe for authorization and cookie, but you still log every other header, some of which may be personal. Log only the headers you need. (d) half leaks: creditCard is redacted by default (the list also knows cardNumber, cvv and ssn), but phone is personal data the logger does not know about. Add it with redact: { keys: ["phone"] }. Better still, log only the userId, and never put card data in a log object at all.

Recap

  • Structured logs are records: a short fixed message plus named metadata fields that tools can search.
  • A formatter turns an entry into text (readable text or JSON). A transport sends it somewhere. A transport can be a plain function.
  • Six levels, from fatal to trace. The threshold (default info) drops the less serious ones. Read it from LOG_LEVEL and reject typos.
  • Child loggers add a name and metadata. withContext adds a request id to every line of one request.
  • Log errors once, with the error object. 4xx is warn, 5xx is error.
  • Redaction hides secret field names, but never text inside the message. Log injection is escaped.
  • In production: one JSON object per line on standard output, and flush before exit.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.