# Pino

`@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.

```sh
npm install @newinstance/bugwatch
```

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

## Wiring: keep stdout, add BugWatch

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.

```ts
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 thresholds

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

| Pino level | BugWatch level |
| ---------- | -------------- |
| 10         | trace          |
| 20         | debug          |
| 30         | info           |
| 40         | warn           |
| 50         | error          |
| 60         | fatal          |

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 tags

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.

```ts
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:

```ts
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` field

`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`.

```ts
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 enrichment

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 correlation

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.

```ts
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 example

```ts
// 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.

## Troubleshooting

**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.
