diff --git a/apps/landing/public/screenshots/guides/pino-logger-01-logs.webp b/apps/landing/public/screenshots/guides/pino-logger-01-logs.webp new file mode 100644 index 0000000000..59b7b3007d Binary files /dev/null and b/apps/landing/public/screenshots/guides/pino-logger-01-logs.webp differ diff --git a/apps/landing/public/screenshots/guides/pino-logger-02-log-detail.webp b/apps/landing/public/screenshots/guides/pino-logger-02-log-detail.webp new file mode 100644 index 0000000000..132e6e2f2a Binary files /dev/null and b/apps/landing/public/screenshots/guides/pino-logger-02-log-detail.webp differ diff --git a/apps/landing/public/screenshots/guides/pino-logger-03-trace.webp b/apps/landing/public/screenshots/guides/pino-logger-03-trace.webp new file mode 100644 index 0000000000..f136c0c8c3 Binary files /dev/null and b/apps/landing/public/screenshots/guides/pino-logger-03-trace.webp differ diff --git a/apps/landing/src/components/docs/InstallTabs.astro b/apps/landing/src/components/docs/InstallTabs.astro new file mode 100644 index 0000000000..0db0ebc53b --- /dev/null +++ b/apps/landing/src/components/docs/InstallTabs.astro @@ -0,0 +1,24 @@ +--- +// An npm install command with pnpm and bun twins, in a package-manager switcher +// that shares its remembered pick with the docs' install blocks. +// `variants` overrides the conversion for commands it can't handle (npx, pipes). +import LanguageTabs from "./LanguageTabs.astro"; +import LanguageTab from "./LanguageTab.astro"; +import { installVariants } from "../../lib/remark-install-tabs.mjs"; +import { highlight } from "../../lib/guide-highlight"; + +interface Props { + command?: string; + variants?: Array<{ id: string; value: string }>; +} + +const { command = "", variants = installVariants(command)?.variants ?? [{ id: "npm", value: command }] } = Astro.props; +--- + + ({ id, label: id }))}> + {variants.map((v) => ( + +
+
+ ))} +
diff --git a/apps/landing/src/pages/guides/index.astro b/apps/landing/src/pages/guides/index.astro index 59dca929a2..af8576664d 100644 --- a/apps/landing/src/pages/guides/index.astro +++ b/apps/landing/src/pages/guides/index.astro @@ -56,6 +56,13 @@ const coreGuides = [ description: "The four metric types with examples, the handful worth watching, and how to collect and chart them.", read: "7 min", }, + { + href: "/guides/pino-logger", + label: "Logging", + title: "Pino logger for Node.js", + description: "Set up Pino, configure it for production, log HTTP requests, and tie every line to the trace it came from.", + read: "9 min", + }, { href: "/guides/new-relic-pricing", label: "Pricing", diff --git a/apps/landing/src/pages/guides/pino-logger.astro b/apps/landing/src/pages/guides/pino-logger.astro new file mode 100644 index 0000000000..530655c5b0 --- /dev/null +++ b/apps/landing/src/pages/guides/pino-logger.astro @@ -0,0 +1,834 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import Nav from "../../components/Nav.astro"; +import Footer from "../../components/Footer.astro"; +import BottomCTA from "../../components/BottomCTA.astro"; +import Breadcrumb from "../../components/Breadcrumb.astro"; +import FAQ from "../../components/FAQ.astro"; +import SeoHead from "../../components/SeoHead.astro"; +import TableOfContents from "../../components/TableOfContents.astro"; +import { CONTRIBUTORS } from "../../lib/changelog-meta"; +import { highlight } from "../../lib/guide-highlight"; +import LanguageTabs from "../../components/docs/LanguageTabs.astro"; +import LanguageTab from "../../components/docs/LanguageTab.astro"; +import InstallTabs from "../../components/docs/InstallTabs.astro"; + +const pageTitle = "Pino logger for Node.js: setup, levels, trace IDs | Maple"; +const pageDescription = + "Set up the Pino logger in Node.js: log levels, child loggers, redaction, pino-pretty, transports, pino-http and Fastify, and trace IDs with OpenTelemetry."; +const published = "2026-10-19"; +const updatedLabel = "October 20, 2026"; +const author = CONTRIBUTORS.jeremyfunk; +const authorUrl = `https://github.com/${author.github}`; + +const toc = [ + { depth: 2, slug: "what-is-pino", text: "Pino in brief" }, + { depth: 2, slug: "setup", text: "Install and log" }, + { depth: 3, slug: "first-log", text: "The first log line" }, + { depth: 3, slug: "log-levels", text: "Log levels" }, + { depth: 3, slug: "child-loggers", text: "Child loggers" }, + { depth: 2, slug: "production", text: "Configure for production" }, + { depth: 3, slug: "redaction", text: "Redaction and serializers" }, + { depth: 3, slug: "pino-pretty", text: "pino-pretty" }, + { depth: 3, slug: "transports", text: "Transports" }, + { depth: 3, slug: "destinations", text: "Sync, async and buffered" }, + { depth: 2, slug: "http", text: "HTTP request logs" }, + { depth: 2, slug: "opentelemetry", text: "Trace IDs and Maple" }, + { depth: 3, slug: "otel-setup", text: "Start the SDK" }, + { depth: 3, slug: "correlated", text: "The correlated line" }, + { depth: 3, slug: "maple", text: "Find the logs in Maple" }, + { depth: 2, slug: "pino-vs-winston", text: "Pino vs Winston" }, +]; +const sectionNav = toc.filter((h) => h.depth === 2); + +// One color per place a log line passes through, used by the diagram, badges and code. +const stages = { + main: { name: "Main thread", color: "var(--chart-1)" }, + worker: { name: "Worker thread", color: "var(--chart-2)" }, + destination: { name: "Destination", color: "var(--chart-3)" }, + otel: { name: "OpenTelemetry", color: "var(--chart-5)" }, +} as const; + +const levels = [ + { name: "fatal", value: "60", use: "The process is about to exit.", tone: "var(--destructive)" }, + { name: "error", value: "50", use: "A request or job failed and someone may need to act.", tone: "var(--destructive)" }, + { name: "warn", value: "40", use: "Something went wrong and the code recovered: a retry, a fallback.", tone: "var(--warning)" }, + { name: "info", value: "30", use: "The default. Lifecycle events and one line per unit of work.", tone: "var(--foreground)" }, + { name: "debug", value: "20", use: "Detail you turn on while investigating.", tone: "var(--muted-foreground)" }, + { name: "trace", value: "10", use: "Very verbose output, usually only on your machine.", tone: "var(--muted-foreground)" }, +]; + +const firstLogBody = `logger.info("server started") +logger.info({ port: 3000 }, "listening") +logger.info("user %s signed in after %d ms", "ada", 42) +logger.error(new Error("connection refused"), "payment provider unreachable")`; + +const firstLogTabs = [ + { + id: "typescript", + label: "TypeScript", + run: "node app.ts # Node 22.18+, in a package with \"type\": \"module\"", + code: `// app.ts\nimport pino, { type Logger } from "pino"\n\nconst logger: Logger = pino()\n\n${firstLogBody}`, + }, + { id: "esm", label: "ES modules", run: "node app.mjs", code: `// app.mjs\nimport pino from "pino"\n\nconst logger = pino()\n\n${firstLogBody}` }, + { id: "commonjs", label: "CommonJS", run: "node app.cjs", code: `// app.cjs\nconst pino = require("pino")\n\nconst logger = pino()\n\n${firstLogBody}` }, +]; + +const firstLogOutput = `{"level":30,"time":1792513464618,"pid":4812,"hostname":"api-1","msg":"server started"} +{"level":30,"time":1792513464619,"pid":4812,"hostname":"api-1","port":3000,"msg":"listening"} +{"level":30,"time":1792513464619,"pid":4812,"hostname":"api-1","msg":"user ada signed in after 42 ms"} +{"level":50,"time":1792513464619,"pid":4812,"hostname":"api-1","err":{"type":"Error","message":"connection refused","stack":"Error: connection refused\\n at file:///srv/app.ts:8:14 ..."},"msg":"payment provider unreachable"}`; + +const levelSnippet = `const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }) + +logger.debug("dropped: debug is below info") + +logger.level = "debug" // change it at runtime, for example from an admin endpoint +logger.debug("written now") + +if (logger.isLevelEnabled("trace")) { + logger.trace({ memory: process.memoryUsage() }, "memory snapshot") +}`; + +const childSnippet = `const logger = pino() + +const billing = logger.child({ module: "billing" }) +const log = billing.child({ requestId: "req_8f2c" }) + +log.warn({ invoiceId: "inv_1042" }, "invoice overdue") + +const db = logger.child({ module: "db" }, { level: "warn" }) +db.info("not written: this child only logs warn and above")`; + +const childOutput = `{"level":40,"time":1792513481449,"pid":4812,"hostname":"api-1","module":"billing","requestId":"req_8f2c","invoiceId":"inv_1042","msg":"invoice overdue"}`; + +const redactSnippet = `const logger = pino({ + redact: { + paths: ["password", "req.headers.authorization", 'req.headers["x-api-key"]', "cards[*].number"], + censor: "[REDACTED]", + }, + serializers: { + user: (user) => ({ id: user.id, plan: user.plan }), + }, +}) + +logger.info({ password: "hunter2", cards: [{ number: "4242424242424242", brand: "visa" }] }, "signup") +logger.info({ user: { id: "u_1", plan: "pro", email: "ada@example.com" } }, "loaded user")`; + +const redactOutput = `{"level":30,...,"password":"[REDACTED]","cards":[{"number":"[REDACTED]","brand":"visa"}],"msg":"signup"} +{"level":30,...,"user":{"id":"u_1","plan":"pro"},"msg":"loaded user"}`; + +const prettySnippet = `const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + transport: + process.env.NODE_ENV === "development" + ? { + target: "pino-pretty", + options: { colorize: true, translateTime: "SYS:HH:MM:ss", ignore: "pid,hostname" }, + } + : undefined, +})`; + +// pino-pretty's terminal output, colored the way it prints. +const prettyOutput = `[18:24:50] INFO: listening + port: 3000`; + +const prettyFlags = `-c -t "SYS:HH:MM:ss" -i pid,hostname`; +const prettyCli = [ + { id: "npm", value: `node app.ts | npx pino-pretty ${prettyFlags}` }, + { id: "pnpm", value: `node app.ts | pnpm exec pino-pretty ${prettyFlags}` }, + { id: "bun", value: `node app.ts | bunx pino-pretty ${prettyFlags}` }, +]; + +const transportSnippet = `const transport = pino.transport({ + targets: [ + { target: "pino-pretty", level: "info", options: { colorize: true } }, + { target: "pino/file", level: "debug", options: { destination: "./logs/app.log", mkdir: true } }, + ], +}) + +const logger = pino({ level: "debug" }, transport) + +logger.debug("only in the file") +logger.info({ orderId: "ord_77" }, "in the terminal and in the file")`; + +const destinationSnippet = `// Collect 4 KiB before each write: more throughput, and a crash can lose the last buffer. +const logger = pino(pino.destination({ dest: "./logs/app.log", minLength: 4096, mkdir: true })) + +// Block on every write: nothing is pending when the function returns. +const lambdaLogger = pino(pino.destination({ sync: true }))`; + +const writeModes = [ + { setting: "Default destination", runs: stages.main, behaviour: "Hands each line to stdout without blocking or buffering", when: "Most services" }, + { setting: "minLength: 4096", runs: stages.main, behaviour: "Collects 4 KiB, then writes", when: "Very high volume, where losing the last buffer on a crash is acceptable" }, + { setting: "sync: true", runs: stages.main, behaviour: "Blocks until each write completes", when: "AWS Lambda and other runtimes that freeze the process after a response" }, + { setting: "pino.transport()", runs: stages.worker, behaviour: "Formatting and I/O move to a worker", when: "Pretty printing, several files, sending over the network" }, +]; + +const httpTabs = [ + { + id: "express", + label: "Express", + install: "npm install express pino pino-http", + code: `// server.mjs +import express from "express" +import pino from "pino" +import { pinoHttp } from "pino-http" + +const logger = pino({ + level: process.env.LOG_LEVEL ?? "info", + redact: ["req.headers.authorization", "req.headers.cookie"], +}) +const app = express() + +app.use( + pinoHttp({ + logger, + quietReqLogger: true, + customLogLevel: (req, res, err) => + err || res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info", + }), +) + +app.get("/orders/:id", (req, res) => { + req.log.info({ orderId: req.params.id }, "loading order") + res.json({ id: req.params.id }) +}) + +app.listen(3000, () => logger.info({ port: 3000 }, "listening"))`, + note: "pino-http writes one request completed line per request with the request, the status code and responseTime in milliseconds. Its default request serializer includes every header, which is why the logger redacts authorization and cookie. quietReqLogger keeps the lines your handlers write down to a reqId instead of the whole request, and customLogLevel logs 4xx as warn and 5xx as error. The older express-pino-logger package is deprecated in favour of pino-http.", + }, + { + id: "fastify", + label: "Fastify", + install: "npm install fastify", + code: `// server.mjs +import Fastify from "fastify" + +const app = Fastify({ + logger: { level: process.env.LOG_LEVEL ?? "info" }, +}) + +app.get("/orders/:id", async (request) => { + request.log.info({ orderId: request.params.id }, "loading order") + return { id: request.params.id } +}) + +await app.listen({ port: 3000 })`, + note: "Fastify depends on Pino and builds the logger from its logger option, which takes true or any Pino options, including redact and transport. It writes incoming request and request completed lines with a reqId, and its default request serializer logs no headers. To pass a Pino instance you created yourself, Fastify 5 has a separate loggerInstance option.", + }, +]; + +const otelInstall = `npm install @opentelemetry/api \\ + @opentelemetry/sdk-node \\ + @opentelemetry/auto-instrumentations-node \\ + @opentelemetry/instrumentation \\ + @opentelemetry/resources \\ + @opentelemetry/sdk-logs \\ + @opentelemetry/sdk-metrics \\ + @opentelemetry/exporter-trace-otlp-proto \\ + @opentelemetry/exporter-logs-otlp-proto \\ + @opentelemetry/exporter-metrics-otlp-proto`; + +const tracingSnippet = `// tracing.mjs +import { register } from "node:module" +import { NodeSDK } from "@opentelemetry/sdk-node" +import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node" +import { resourceFromAttributes } from "@opentelemetry/resources" +import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs" +import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics" +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto" +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto" +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto" + +// Lets the instrumentations patch ES modules. CommonJS apps can drop this line. +register("@opentelemetry/instrumentation/hook.mjs", import.meta.url) + +const MAPLE_ENDPOINT = "https://ingest.maple.dev" // EU: https://ingest.eu.maple.dev +const MAPLE_KEY = "YOUR_INGEST_KEY" +const headers = { authorization: \`Bearer \${MAPLE_KEY}\` } + +const sdk = new NodeSDK({ + resource: resourceFromAttributes({ "service.name": "orders-api" }), + traceExporter: new OTLPTraceExporter({ url: \`\${MAPLE_ENDPOINT}/v1/traces\`, headers }), + logRecordProcessors: [ + new BatchLogRecordProcessor({ + exporter: new OTLPLogExporter({ url: \`\${MAPLE_ENDPOINT}/v1/logs\`, headers }), + }), + ], + metricReaders: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: \`\${MAPLE_ENDPOINT}/v1/metrics\`, headers }), + }), + ], + instrumentations: [getNodeAutoInstrumentations()], +}) + +sdk.start() + +process.on("SIGTERM", () => { + sdk.shutdown().finally(() => process.exit(0)) +})`; + +const disableSendingSnippet = `instrumentations: [ + getNodeAutoInstrumentations({ + "@opentelemetry/instrumentation-pino": { disableLogSending: true }, + }), +],`; + +const correlatedOutput = `{"level":30,"time":1792513527238,"pid":4812,"hostname":"api-1","reqId":1,"trace_id":"0c8fecc47de79cc6181bbc93a912d762","span_id":"22497105575e6deb","trace_flags":"01","orderId":"42","msg":"loading order"}`; + +const winston = [ + ["Default output", "One JSON object per line", "Whatever format builds; JSON is one option"], + ["Levels", "trace 10 up to fatal 60", "npm levels: error 0 down to silly 6"], + ["Formatting", "Outside the process: pino-pretty or your log tooling", "In the process with format.combine()"], + ["Transports", "Worker threads via pino.transport()", "Streams on the main thread (Console, File, Http)"], + ["Redaction", "Built-in redact option", "A custom format"], + ["OpenTelemetry", "Instrumentation adds trace IDs and sends records", "Instrumentation adds trace IDs; sending needs @opentelemetry/winston-transport"], +]; + +const faqs = [ + { + question: "What is Pino in Node.js?", + answer: + "Pino is an open-source (MIT) logger for Node.js that writes each log entry as one line of JSON. It keeps the work on the main thread small, and moves pretty printing and shipping to transports that run in worker threads. Fastify uses Pino as its built-in logger.", + }, + { + question: "Pino vs Winston: which should I use?", + answer: + "Pick Pino when your logs are JSON that something outside the process collects, which is the usual setup in containers, and per-call cost matters. Pick Winston when the process itself must write several formats to several destinations, or your codebase already relies on its format pipeline. Both have OpenTelemetry instrumentations that add trace IDs.", + }, + { + question: "How do I pretty print Pino logs?", + answer: + "Install pino-pretty as a dev dependency. Either pipe the output through it (node app.ts | npx pino-pretty) or set transport: { target: \"pino-pretty\" } in the logger options only when NODE_ENV is development. Keep production output as JSON so log tooling can read the fields.", + }, + { + question: "How do I set the Pino log level?", + answer: + "Pass level in the options, for example pino({ level: process.env.LOG_LEVEL ?? \"info\" }). The default is info. You can change it at runtime with logger.level = \"debug\", and give a child logger its own level with logger.child(bindings, { level: \"warn\" }).", + }, + { + question: "Does Pino work with TypeScript?", + answer: + "Yes. Pino ships its own type definitions, so import pino, { type Logger } from \"pino\" works without an @types package. The Logger type is what you use for function parameters that take a logger or a child logger.", + }, + { + question: "How do I add trace IDs to Pino logs?", + answer: + "Run your app with the OpenTelemetry Node.js SDK and its auto-instrumentations. The pino instrumentation adds trace_id, span_id and trace_flags to every line written inside an active span, and sends each record to the OpenTelemetry log SDK, which exports it over OTLP. Your logging calls stay the same.", + }, + { + question: "Is Pino synchronous or asynchronous?", + answer: + "The default destination writes to stdout without blocking and without buffering, and Pino flushes what is pending when the process exits normally. pino.destination({ minLength: 4096 }) adds a buffer for more throughput. sync: true blocks on every write, which Pino recommends for AWS Lambda.", + }, +]; + +const siteUrl = Astro.site?.toString().replace(/\/$/, "") ?? "https://maple.dev"; +const articleJsonLd = { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: "Pino logger for Node.js", + description: pageDescription, + datePublished: published, + dateModified: "2026-10-20", + author: { "@type": "Person", name: author.name, url: authorUrl }, + publisher: { + "@type": "Organization", + name: "Maple", + logo: { "@type": "ImageObject", url: `${siteUrl}/logo192.png` }, + }, + mainEntityOfPage: `${siteUrl}/guides/pino-logger`, +}; +--- + + + +