pino logging with redaction, request context and a custom transport: working versions (tested 2026-10-15)
A pino 10 logger with path and wildcard redaction, error serialisers, string levels and an AsyncLocalStorage mixin for request ids, plus a pino-abstract-transport alert sink combined via multistream, tested offline against in-memory streams.
passed5 passed · 0 failed2026-10-15 15:02 UTC- Status
- passed
- Last passed
- 2026-10-15 15:02 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions4
pino@10.4.0pino-abstract-transport@3.0.0tsx@4.23.15@types/node@26.6.5
run it
npm install --no-audit --no-fund --ignore-scripts=false npm test
test environment
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38755782594
The code
Exactly the files that passed. Your agent can pull them with get_recipe("pino-redaction-custom-transport") (install), or see them on GitHub.
package.json
{
"name": "pino-redaction-custom-transport",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"pino": "10.4.0",
"pino-abstract-transport": "3.0.0"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "26.6.5"
}
}
src/logger.ts
import { AsyncLocalStorage } from "node:async_hooks";
import pino, { type DestinationStream } from "pino";
import build from "pino-abstract-transport";
export const requestContext = new AsyncLocalStorage<{ requestId: string; userId?: string }>();
/**
* App logger: redacts secrets, serialises errors, and stamps every line with the current
* request id from AsyncLocalStorage through a mixin.
*/
export function createLogger(destination: DestinationStream, level = "info") {
return pino({
level,
base: { service: "billing" },
timestamp: pino.stdTimeFunctions.isoTime,
formatters: { level: (label) => ({ level: label }) }, // "level":"info" instead of 30
redact: {
paths: ["password", "*.password", "headers.authorization", "card.number", "users[*].email"],
censor: "[REDACTED]",
},
serializers: { err: pino.stdSerializers.err },
mixin: () => requestContext.getStore() ?? {},
}, destination);
}
/**
* A custom transport built with pino-abstract-transport. It receives parsed log objects and
* forwards only warn and above to a sink (a stand-in for Slack, Sentry, a queue...).
* Used here in-process as a destination; the same module can run in a worker via pino.transport.
*/
export async function alertTransport(opts: { sink: (line: { level: string; msg: string; requestId?: string }) => void; minLevel?: number }) {
const min = opts.minLevel ?? 40;
const levelNum: Record<string, number> = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60 };
return build(async (source) => {
for await (const obj of source) {
if ((levelNum[obj.level] ?? obj.level) >= min) opts.sink({ level: obj.level, msg: obj.msg, requestId: obj.requestId });
}
});
}
test/logger.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { Writable } from "node:stream";
import pino from "pino";
import { createLogger, requestContext, alertTransport } from "../src/logger.ts";
/** Collects JSON lines written by pino. */
function memory() {
const lines: any[] = [];
const stream = new Writable({ write(chunk, _enc, cb) { for (const l of chunk.toString().split("\n").filter(Boolean)) lines.push(JSON.parse(l)); cb(); } });
return { stream, lines };
}
test("redacts secrets at top level, nested, and in arrays", () => {
const m = memory();
const log = createLogger(m.stream);
log.info({ password: "hunter2", headers: { authorization: "Bearer abc", host: "x" }, card: { number: "4242424242424242", last4: "4242" }, users: [{ email: "[email protected]", id: 1 }] }, "signup");
const [l] = m.lines;
assert.equal(l.password, "[REDACTED]");
assert.equal(l.headers.authorization, "[REDACTED]");
assert.equal(l.headers.host, "x");
assert.equal(l.card.number, "[REDACTED]");
assert.equal(l.card.last4, "4242");
assert.equal(l.users[0].email, "[REDACTED]");
assert.equal(l.level, "info");
assert.equal(l.service, "billing");
assert.match(l.time, /^\d{4}-\d{2}-\d{2}T/);
});
test("child loggers add bindings and wildcard redaction still applies", () => {
const m = memory();
const child = createLogger(m.stream).child({ module: "invoices" });
child.warn({ customer: { password: "pw", id: "c1" } }, "odd login");
assert.equal(m.lines[0].module, "invoices");
assert.equal(m.lines[0].customer.password, "[REDACTED]");
assert.equal(m.lines[0].customer.id, "c1");
});
test("errors are serialised with type, message and stack", () => {
const m = memory();
createLogger(m.stream).error({ err: new TypeError("bad input") }, "failed");
assert.equal(m.lines[0].err.type, "TypeError");
assert.equal(m.lines[0].err.message, "bad input");
assert.match(m.lines[0].err.stack, /TypeError: bad input/);
});
test("mixin stamps the AsyncLocalStorage request context on every line", async () => {
const m = memory();
const log = createLogger(m.stream, "debug");
await requestContext.run({ requestId: "req-42", userId: "u7" }, async () => {
log.debug("start");
await new Promise((r) => setTimeout(r, 1));
log.info("after await");
});
log.info("outside");
assert.deepEqual(m.lines.map((l) => [l.msg, l.requestId ?? null]), [["start", "req-42"], ["after await", "req-42"], ["outside", null]]);
assert.equal(m.lines[0].userId, "u7");
});
test("custom pino-abstract-transport forwards only warn and above", async () => {
const alerts: { level: string; msg: string; requestId?: string }[] = [];
const transport = await alertTransport({ sink: (a) => alerts.push(a) });
const m = memory();
const log = createLogger(pino.multistream([{ stream: m.stream, level: "debug" }, { stream: transport, level: "debug" }]), "debug");
requestContext.run({ requestId: "req-9" }, () => {
log.info("fine");
log.warn("disk 91%");
log.error("payment provider down");
});
transport.end();
await new Promise((r) => transport.on("close", r));
assert.equal(m.lines.length, 3);
assert.deepEqual(alerts, [
{ level: "warn", msg: "disk 91%", requestId: "req-9" },
{ level: "error", msg: "payment provider down", requestId: "req-9" },
]);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.