Bunyan

Forwarding Bunyan logs to BugWatchlink

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.

Installlink

npm install @newinstance/bugwatch bunyan

Wiringlink

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

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 mappinglink

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

BunyanNumericBugWatch
trace10trace
debug20debug
info30info
warn40warn
error50error
fatal60fatal

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

What becomes tagslink

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.

// 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 handlinglink

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.

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 correlationlink

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:

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

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

Complete examplelink

// 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");
}

Troubleshootinglink

  • 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).