Logging from First Principles
- Why you need logs
- console.log is a logger
- Structured logging
- Log levels
- Using pino
- Logging requests in Hectoday
- The request ID
- What to log
- Always log
- Log when useful
- Never log
- Logging inside handlers
- Slow request logging
- Log levels in practice
- Where logs go
- Development
- Managed platforms
- VPS
- Correlation across services
- Alerting
- Summary
A guide for developers who console.log everything in development and nothing in production. Every example uses @hectoday/http, but the ideas apply to any server.
Why you need logs
Your app is running on a server you can't see. A user reports something is broken. You have no debugger, no browser console, no one watching the terminal. Logs are the only record of what happened.
Without logs, debugging production is guesswork. With logs, you read the story of what happened, in order, with timestamps.
console.log is a logger
The simplest logger is console.log. It works. Every runtime supports it. In production, stdout goes to whatever log collector your platform provides.
console.log("Server started on port 3000");
console.log("User created:", userId);
console.error("Database connection failed:", error);This is fine for small apps. It breaks down when you need to search logs, filter by severity, or parse them programmatically. The output is unstructured text. A human can read it. A machine can't.
Structured logging
Structured logs are JSON. Every log entry is a parseable object with consistent fields:
// Unstructured
console.log("POST /users 201 45ms");
// Structured
console.log(
JSON.stringify({
method: "POST",
path: "/users",
status: 201,
duration: 45,
timestamp: new Date().toISOString(),
}),
);The unstructured log is readable. The structured log is searchable. You can filter by status >= 500, sort by duration, aggregate by path. Log platforms (Axiom, Datadog, Grafana) ingest JSON and give you dashboards, alerts, and queries.
Log levels
Not every log is equally important. Levels tell you the severity:
error — something broke. A handler threw. A database query failed. The app is in a bad state. You need to look at this.
warn — something is wrong but the app handled it. A deprecated endpoint was called. A retry succeeded after a failure. Rate limit hit.
info — normal operations worth recording. Request completed. User created. Config loaded. This is the default level in production.
debug — detailed information for troubleshooting. Input values, intermediate state, cache hits. Too noisy for production. Useful in development.
log.error("Database connection failed", { error: String(err) });
log.warn("Deprecated endpoint called", { path: "/v1/users" });
log.info("Request completed", { method: "GET", path: "/users", status: 200, duration: 12 });
log.debug("Cache hit", { key: "user:123" });In production, set the level to info. You see info, warn, and error. Debug is silenced. In development, set it to debug and see everything.
Using pino
pino is a fast JSON logger for Node.js and Bun. It outputs one JSON line per log entry.
npm install pinoimport pino from "pino";
const log = pino({ level: process.env.LOG_LEVEL || "info" });
log.info({ port: 3000 }, "Server started");
log.error({ err }, "Database connection failed");Output:
{"level":30,"time":1711100000000,"msg":"Server started","port":3000}
{"level":50,"time":1711100000001,"msg":"Database connection failed","err":{"message":"connection refused"}}One line per entry. Parseable JSON. Timestamp and level included automatically.
For readable output in development, pipe through pino-pretty:
npm install -D pino-pretty
node server.ts | npx pino-prettyThis turns the JSON into colored, human-readable output in your terminal. In production, skip it and let the raw JSON go to your log collector.
Logging requests in Hectoday
The hooks are your logger. onRequest captures the start. onResponse logs the result.
import pino from "pino";
const log = pino({ level: process.env.LOG_LEVEL || "info" });
const app = setup({
onRequest: ({ request }) => ({
requestId: crypto.randomUUID(),
startTime: Date.now(),
}),
routes: [...],
onResponse: ({ request, response, locals }) => {
const duration = Date.now() - locals.startTime;
const url = new URL(request.url);
log.info({
requestId: locals.requestId,
method: request.method,
path: url.pathname,
status: response.status,
duration,
});
return response;
},
onError: ({ error, request, locals }) => {
log.error({
requestId: locals.requestId,
method: request.method,
path: new URL(request.url).pathname,
error: String(error),
stack: error instanceof Error ? error.stack : undefined,
});
return Response.json({ error: "Internal server error" }, { status: 500 });
},
});Every request gets logged with its method, path, status, and duration. Errors get logged with the full stack trace. The requestId connects all logs for the same request.
The request ID
A request ID is a unique identifier assigned to every request. It ties together all the logs, errors, and events that happened during one request.
Generate it in onRequest:
onRequest: ({ request }) => ({
requestId: crypto.randomUUID(),
}),Include it in every log entry:
log.info({ requestId: locals.requestId, ... });Return it to the client so they can report it in bug reports:
onResponse: ({ response, locals }) => {
const headers = new Headers(response.headers);
headers.set("x-request-id", locals.requestId);
return new Response(response.body, { status: response.status, headers });
};When a user says "something went wrong," they give you the request ID from the response header. You search your logs for that ID and see everything that happened.
What to log
Always log
- Every request: method, path, status, duration
- Every error: message, stack trace, request context
- Startup: port, environment, config loaded
Log when useful
- Auth failures: which token failed, why (expired, invalid, missing)
- Slow requests: anything over a threshold (e.g., 1 second)
- External calls: API requests to third parties, database queries that are slow
- Business events: user created, payment processed, email sent
Never log
- Passwords, tokens, API keys, or secrets
- Full request bodies in production (may contain PII)
- Credit card numbers, SSNs, or health data
- Anything you wouldn't want in a leaked log file
Redact sensitive headers:
onResponse: ({ request, response, locals }) => {
const duration = Date.now() - locals.startTime;
log.info({
requestId: locals.requestId,
method: request.method,
path: new URL(request.url).pathname,
status: response.status,
duration,
// Don't log: request.headers.get("authorization")
});
return response;
};Logging inside handlers
For specific events inside a handler, log directly:
resolve: async (c) => {
const caller = authenticate(c.request);
if (caller instanceof Response) return caller;
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const user = await db.users.create(c.input.body);
log.info({
event: "user_created",
userId: user.id,
createdBy: caller.id,
});
return Response.json(user, { status: 201 });
};Use an event field to categorize business events. This lets you search for all user_created events, count them, or alert on anomalies.
Slow request logging
Flag requests that take too long:
onResponse: ({ request, response, locals }) => {
const duration = Date.now() - locals.startTime;
const url = new URL(request.url);
const entry = {
requestId: locals.requestId,
method: request.method,
path: url.pathname,
status: response.status,
duration,
};
if (duration > 1000) {
log.warn({ ...entry, slow: true }, "Slow request");
} else {
log.info(entry);
}
return response;
};Search your logs for slow: true to find performance problems.
Log levels in practice
Set the level via environment variable:
const log = pino({ level: process.env.LOG_LEVEL || "info" });# Development — see everything
LOG_LEVEL=debug node server.ts
# Production — info and above
LOG_LEVEL=info node server.ts
# Debugging production — temporarily enable debug
LOG_LEVEL=debug node server.tsDon't change your code to change the log level. Change the environment variable and restart.
Where logs go
Development
Stdout. Your terminal. Use pino-pretty for readable output.
Managed platforms
Deno Deploy, Railway, Fly.io, Cloudflare Workers all capture stdout and show it in their dashboards. Just console.log or use pino. The platform handles collection.
VPS
pm2 captures stdout:
pm2 logs api # tail logs
pm2 logs api --lines 100 # last 100 linesFor long-term storage and search, pipe logs to a service:
# Axiom
node server.ts | axiom-transport
# Or configure pino to send to a transportpino supports transports that send logs to Axiom, Datadog, Loki, Elasticsearch, or any HTTP endpoint. Configure them in production, not in your app code.
Correlation across services
If your system has multiple services (API, worker, email service), the request ID lets you trace a request across all of them. Pass it in headers when making internal calls:
const response = await fetch("http://email-service/send", {
method: "POST",
headers: {
"content-type": "application/json",
"x-request-id": requestId,
},
body: JSON.stringify({ to: user.email, template: "welcome" }),
});The email service reads x-request-id and includes it in its own logs. Now you can search for one ID and see the full journey across services.
This is the basic version of distributed tracing. Tools like OpenTelemetry formalize it, but a request ID in a header gets you 80% of the value.
Alerting
Logs are useless if nobody reads them. Set up alerts for:
- Error rate spikes — more than N errors per minute
- Slow request spikes — more than N slow requests per minute
- Specific errors — database connection failures, auth service down
- Zero traffic — if your health check stops getting hits, something is wrong
Every log platform supports alerts. The minimum: alert on error rate. If errors spike, you know something broke before users report it.
Summary
| Concept | What it means |
|---|---|
| Structured logging | JSON output, one line per entry, machine-parseable |
| Log levels | error > warn > info > debug. Set via environment variable. |
| Request ID | Unique ID per request, ties all logs together |
| pino | Fast JSON logger. Use pino-pretty in development. |
onResponse |
Log every request: method, path, status, duration |
onError |
Log every error: message, stack trace, request context |
| Redaction | Never log tokens, passwords, or PII |
| Alerting | Get notified when error rates spike |
Log every request. Log every error. Include a request ID. Redact secrets. Send JSON. Alert on spikes. That covers 95% of production logging needs.