Pino

Forwarding Pino logs to BugWatchlink

@newinstance/bugwatch ships a Pino destination in the same package, exposed as the ./pino subpath export. It forwards every log record Pino writes to it, in addition to your normal stdout output. No separate plugin package.

npm install @newinstance/bugwatch

Get a project DSN key from the BugWatch dashboard at www.newinstance.cloud: open your project, then BugWatch → Settings → Data Source Name (DSN) keys.

Wiring: keep stdout, add BugWatchlink

The adapter is a destination (an object with a write(line: string) method), not a Pino transport. Combine it with pino.multistream so records go to both stdout and BugWatch.

import pino from "pino";
import { createClient } from "@newinstance/bugwatch";
import { createBugWatchPinoDestination } from "@newinstance/bugwatch/pino";

const client = createClient({ projectKey: process.env.BUGWATCH_KEY! });

const logger = pino(
	{ level: "info" },
	pino.multistream([
		{ stream: process.stdout },
		{ level: "warn", stream: createBugWatchPinoDestination(client) },
	]),
);

If you only want BugWatch and nothing else, pass the destination directly: pino(createBugWatchPinoDestination(client)).

write is called synchronously by Pino and never throws back into it. Malformed NDJSON lines are silently ignored, and the destination does not block Pino's caller.

Level mapping and thresholdslink

Pino's numeric levels pass through 1:1. There is no translation table:

Pino levelBugWatch level
10trace
20debug
30info
40warn
50error
60fatal

A record with no numeric level defaults to 30 (info). Custom Pino levels forward as whatever number you assigned them.

There is no level filter inside the adapter. Filtering is Pino's job, at two points:

  • The logger's own level decides what gets serialized at all.
  • The per-stream level in multistream decides what reaches BugWatch. In the example above stdout gets everything from info up, BugWatch gets warn and above.

Sending every info line to BugWatch is usually not what you want. Set the BugWatch stream to warn or error and raise it only when debugging.

Which fields become tagslink

The adapter parses each NDJSON line and walks the top-level keys. It skips level, msg, time, pid, hostname, and err. Of what remains, only scalars become tags: string, number, boolean, and bigint, each stringified. msg becomes the event message.

Nested objects and arrays are dropped silently. There is no flattening and no warning.

logger.warn(
	{
		userId: "u_1",          // tag userId="u_1"
		attempt: 3,             // tag attempt="3"
		cached: false,          // tag cached="false"
		order: { id: "o_9" },   // DROPPED, no tag
		skus: ["a", "b"],       // DROPPED, no tag
	},
	"checkout retry",
);

The workaround is to flatten or stringify at the call site:

logger.warn(
	{
		userId: "u_1",
		orderId: order.id,                 // flattened, arrives as a tag
		skus: order.skus.join(","),        // stringified, arrives as a tag
	},
	"checkout retry",
);

If you want this applied everywhere, do it in a Pino mixin or a child logger binding rather than repeating it per call.

Errors: the err fieldlink

err is excluded from tags and used as the captured exception instead. Pino's default serializer turns an Error into a plain object before the destination ever sees the line, so BugWatch receives { type, message, stack } rather than a live Error.

logger.error({ err: new Error("db timeout"), db: "orders" }, "database error");

That produces one BugWatch event: level 50, message "database error", tag db="orders", and an exception carrying the error's message.

Two consequences of Pino serializing first, both worth knowing:

  • Frame-by-frame stacktrace parsing happens for real Error instances. A record whose err has already been flattened to a plain object contributes its message, not parsed frames.
  • The exception type falls back to Error, because Pino's serializer writes the class name into type while the normalizer reads name. A TypeError shows up as Error with the correct message.

When you need the full parsed stacktrace on a specific failure, call client.captureException(err) with the live Error object and log separately (see the double-capture note in troubleshooting).

Request-scope enrichmentlink

Forwarded logs are ordinary captures, so they pick up the active request scope for free. Framework adapters such as bugWatchExpressRequestHandler open a per-request AsyncLocalStorage scope when the request arrives, and every capture inside that async call tree merges the root scope with the request scope, request scope winning on overlap.

Because Pino calls write synchronously inside your handler, the log lands inside that ALS cell. A logger.error(...) inside a request therefore carries the request's user, request tags (method, url, route), release, and contexts, with none of that repeated in the log call itself. Anything you set with setRequestUser, setRequestTag, or setRequestContext from @newinstance/bugwatch/node rides along too.

Outside any request (startup, cron, workers) the log falls back to the global root scope, so only client.setUser / setTag values apply.

Trace correlationlink

Trace context resolves from the same active scope. Inside a request whose adapter propagated an incoming trace header, or inside client.withSpan(...), forwarded logs carry that traceId and spanId automatically, so log events line up with the span in the BugWatch trace view.

await client.withSpan("charge-card", async () => {
	logger.warn({ gateway: "stripe" }, "gateway slow"); // carries the span's traceId
});

With no active scope trace and a configured traceContextProvider (for example the OpenTelemetry bridge from ./otel), the provider supplies the ids instead.

Complete examplelink

// src/server.ts
import express from "express";
import pino from "pino";
import { createClient } from "@newinstance/bugwatch";
import { createBugWatchPinoDestination } from "@newinstance/bugwatch/pino";
import {
	bugWatchExpressRequestHandler,
	bugWatchExpressErrorHandler,
} from "@newinstance/bugwatch/express";

const client = createClient({
	projectKey: process.env.BUGWATCH_KEY!,
	environment: process.env.NODE_ENV ?? "development",
	release: process.env.GIT_SHA,
});

const logger = pino(
	{ level: process.env.LOG_LEVEL ?? "info" },
	pino.multistream([
		{ stream: process.stdout },
		{ level: "warn", stream: createBugWatchPinoDestination(client) },
	]),
);

const app = express();

// FIRST: opens the per-request scope that enriches every forwarded log.
app.use(bugWatchExpressRequestHandler(client, { getUser: (req) => req.user }));

app.get("/orders/:id", (req, res) => {
	// Scalars only: orderId is a tag, the nested object would be dropped.
	logger.info({ orderId: req.params.id }, "fetching order");
	res.json({ ok: true });
});

app.get("/boom", () => {
	throw new Error("kaboom");
});

// LAST: captures thrown errors, then calls next(err).
app.use(bugWatchExpressErrorHandler(client));

const server = app.listen(3000, () => logger.info("listening on 3000"));

for (const sig of ["SIGINT", "SIGTERM"] as const) {
	process.on(sig, () => {
		server.close(() => {
			void client.close();
		});
	});
}

client.close() flushes anything still queued, which matters for short-lived processes and serverless handlers.

Troubleshootinglink

A field is missing as a tag. It was an object, an array, null, or undefined. Only scalars survive. Flatten it (orderId: order.id) or stringify it yourself. Also check it is not one of the reserved keys level, msg, time, pid, hostname, err, which are consumed rather than tagged.

Nothing arrives at all. Check the per-stream level in multistream against the level you are logging at, and confirm the logger's own level is not filtering the record before serialization. A parse failure on a malformed line is swallowed by design, so a custom serializer that emits invalid JSON produces silence. Turn on debug: true on the client: it prints delivery failures (successes are silent).

Double capture. Calling client.captureException(err) and logger.error({ err }, "...") for the same failure produces two BugWatch events. The same applies when a framework error handler already captures the thrown error and you also log it on the way out. Pick one path per failure: either let the error handler capture and log without err, or drop the error handler capture and rely on the log. If you want both the log line and the parsed stacktrace, log without the err key and call captureException yourself.

Wrong exception type. Everything forwarded through Pino shows as Error, since Pino's serializer strips the class name into type. Use captureException with the live error when the class matters.