# Bunyan

The Bunyan adapter is a Bunyan **raw stream**. It sits alongside your existing streams, so your
normal stdout/file output is untouched and every record that passes the stream's level threshold
is also captured by BugWatch.

## Install

```bash
npm install @newinstance/bugwatch bunyan
```

## Wiring

`createBugWatchBunyanStream(client)` returns an object with a single `write(record)` method.
Bunyan only hands it a record object when the stream entry is declared with `type: "raw"`.

```ts
import bunyan from "bunyan";
import { createClient } from "@newinstance/bugwatch";
import { createBugWatchBunyanStream } from "@newinstance/bugwatch/bunyan";

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

const logger = bunyan.createLogger({
	name: "my-app",
	streams: [
		{ stream: process.stdout },
		{ level: "info", type: "raw", stream: createBugWatchBunyanStream(client) },
	],
});

logger.error({ err: new Error("connection refused") }, "redis down");
```

The `level` on the BugWatch stream entry is your forwarding threshold. Set it to `"warn"` to keep
debug noise local while still shipping warnings and errors.

## Level mapping

Bunyan's numeric levels map 1:1 to BugWatch levels, no translation table involved:

| Bunyan  | Numeric | BugWatch |
| ------- | ------- | -------- |
| `trace` | 10      | trace    |
| `debug` | 20      | debug    |
| `info`  | 30      | info     |
| `warn`  | 40      | warn     |
| `error` | 50      | error    |
| `fatal` | 60      | fatal    |

If a record arrives without a numeric `level`, the adapter falls back to `30` (info).

## What becomes tags

Every remaining top-level field on the record becomes a tag, stringified with `String(v)`.

Skipped keys (Bunyan's own record plumbing): `level`, `msg`, `time`, `v`, `name`, `hostname`,
`pid`, `err`, `src`.

Only **scalar** values are kept: `string`, `number`, `boolean`, `bigint`. Objects, arrays, `null`
and `undefined` are silently dropped, so `logger.info({ order: { id: "o_1" } }, "created")` sends
no tags at all.

Workaround: flatten at the call site, or serialize the value yourself.

```ts
// Dropped: nested object.
logger.info({ order }, "order created");

// Kept: three scalar tags.
logger.info({ orderId: order.id, itemCount: order.items.length, currency: "GBP" }, "order created");

// Kept: pre-serialized as a single string tag.
logger.info({ order: JSON.stringify(order) }, "order created");
```

Note that `record.msg` becomes the event message only when it is a string; `logger.info(obj)` with
no message string produces an event with tags and no message.

## Error handling

The adapter reads the `err` key specifically. If `record.err` is an object it becomes the captured
error on the event. Any other key holding an `Error` (for example `error` or `cause`) is not a
scalar, so it is dropped entirely.

```ts
logger.error({ err }, "payment failed");     // err captured, full stack frames
logger.error({ error: err }, "payment failed"); // error dropped, message only
```

Pass the real `Error` instance if you want a stack trace. If you configure
`serializers: { err: bunyan.stdSerializers.err }`, the raw stream receives Bunyan's plain
`{ name, message, stack }` object instead, and BugWatch still records the type and message but
cannot parse stack frames from it.

The stream never throws back into Bunyan; any internal failure is swallowed so logging can never
break your request path.

## Request scope and trace correlation

The adapter calls `client.captureLog` synchronously inside your `logger.*()` call, so it runs
inside the same `AsyncLocalStorage` async tree that the framework adapter opened. That means
forwarded logs pick up request enrichment for free:

- user set via the adapter's `getUser` option or `setRequestUser`
- request tags set via `setRequestTag`
- global tags set with `BugWatch.setTag`
- `traceId` / `spanId` from the active request scope, `withSpan`, or `traceContextProvider`

Per-call tags from the record win over scope tags on any key collision. For trace correlation with
an existing OpenTelemetry setup, wire the provider once at init and every forwarded Bunyan record
carries the current trace:

```ts
import { otelTraceContextProvider } from "@newinstance/bugwatch/otel";

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

## Complete example

```ts
// src/server.ts
import express, { type NextFunction, type Request, type Response } from "express";
import bunyan from "bunyan";
import { createClient } from "@newinstance/bugwatch";
import { createBugWatchBunyanStream } from "@newinstance/bugwatch/bunyan";
import {
	bugWatchExpressRequestHandler,
	bugWatchExpressErrorHandler,
} from "@newinstance/bugwatch/express";
import { installNodeErrorHandlers, setRequestUser } from "@newinstance/bugwatch/node";

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

installNodeErrorHandlers(client);

const logger = bunyan.createLogger({
	name: "checkout-api",
	streams: [
		{ level: "debug", stream: process.stdout },
		{ level: "info", type: "raw", stream: createBugWatchBunyanStream(client) },
	],
});

const app = express();
app.use(express.json());

// FIRST: opens the per-request AsyncLocalStorage scope.
app.use(bugWatchExpressRequestHandler(client));

app.use((req: Request, _res: Response, next: NextFunction) => {
	const userId = req.headers["x-user-id"];
	if (typeof userId === "string") setRequestUser({ id: userId });
	next();
});

app.post("/checkout", async (req: Request, res: Response) => {
	const orderId = String(req.body.orderId);
	// Scalars only: orderId and amount become tags, request user and trace are added by the scope.
	logger.info({ orderId, amount: Number(req.body.amount) }, "checkout initiated");

	try {
		await processOrder(req.body);
		res.json({ ok: true });
	} catch (err) {
		logger.error({ err, orderId, step: "process" }, "checkout failed");
		res.status(500).json({ ok: false });
	}
});

// LAST: captures anything thrown downstream.
app.use(bugWatchExpressErrorHandler(client));

const server = app.listen(3000, () => logger.info({ port: 3000 }, "listening"));

process.on("SIGTERM", () => {
	server.close(() => {
		void client.close();
	});
});

async function processOrder(_body: unknown): Promise<void> {
	throw new Error("payment gateway timeout");
}
```

## Troubleshooting

- **Garbled or empty events.** The stream entry is missing `type: "raw"`. Without it Bunyan writes
  a JSON string, not a record object, and the adapter has no `level` or `msg` to read.
- **Nothing arrives.** Check both thresholds: the logger's own level and the `level` on the BugWatch
  stream entry. Also confirm `enabled` is not `false` and `sampleRate` is not throttling events.
- **Fields missing as tags.** They were non-scalar or in the skip list. Flatten them or stringify.
- **Error has no stack.** Use the `err` key, pass the real `Error`, and drop `stdSerializers.err`
  from the logger config if you need parsed frames.
- **Events lost on exit.** The queue flushes every 5s by default. Call `await client.close()` (or
  `await client.flush()`) in your shutdown hook.
- **Nothing at all in a short script.** Set `debug: true` at init: it prints delivery failures (successes are silent).
