Winston

Forwarding Winston logs to BugWatchlink

The @newinstance/bugwatch/winston subpath ships a winston-transport implementation that forwards every log record Winston writes to BugWatch, in addition to your existing transports. It does not replace your console or file output.

Installlink

npm install @newinstance/bugwatch

That is the whole install. winston (>= 3) is an optional peer dependency you already have (it brings winston-transport with it). There is no separate plugin package.

Get a project key from the BugWatch dashboard at www.newinstance.cloud, under BugWatch, your project, Settings, DSN keys. Use the sk_test_... key in test and sk_live_... in production.

Adding the transportlink

import winston from "winston";
import { createClient } from "@newinstance/bugwatch";
import { createBugWatchWinstonTransport } from "@newinstance/bugwatch/winston";

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

const logger = winston.createLogger({
	level: "info",
	transports: [
		new winston.transports.Console(),
		createBugWatchWinstonTransport(client),
	],
});

logger.error("payment gateway timeout", { gateway: "stripe" });

The second argument is a standard TransportStreamOptions, so you can forward only the noisy-free tail of your logs while the console keeps everything:

createBugWatchWinstonTransport(client, { level: "warn" });

The transport is failure isolated: anything that goes wrong inside it is swallowed, so a BugWatch problem can never throw into your logging path.

Level mappinglink

Winston level names are translated to BugWatch numeric severities (the Pino-compatible 10 to 60 scale) before the event is sent.

Winston levelBugWatch level
fatal60
error50
warn40
info, http30
verbose, debug20
silly, trace10

Any level name not in that table (a custom level from winston.addColors / custom levels) falls back to info (30). If you use custom level names and care about severity in the dashboard, name them after one of the rows above.

Tags are scalar onlylink

Every property on the Winston info object other than level, message, and stack becomes a BugWatch tag, but only if its value is a string, number, boolean, or bigint. Each value is stringified. Objects, arrays, null, and undefined are dropped so events stay flat.

logger.warn("slow query", {
	table: "orders", // kept, tag table="orders"
	duration_ms: 1200, // kept, tag duration_ms="1200"
	cached: false, // kept, tag cached="false"
	query: { where: { id: 7 } }, // DROPPED, object
});

Workaround: flatten or stringify before logging.

logger.warn("slow query", {
	table: "orders",
	duration_ms: 1200,
	query: JSON.stringify({ where: { id: 7 } }),
});

If the message itself is not a string, it is coerced with String(...).

Errors and stacktraces through Winston (read this)link

Be aware of what the transport does not do. It calls captureLog with a level, a message, and tags. It never passes an error value, and it explicitly skips the stack key on the info object.

Consequences:

  • logger.error(new Error("db down")) arrives as a level 50 log whose message is the error message. No stacktrace, no exception type, no grouping as an exception.
  • winston.format.errors({ stack: true }) does not help. It puts the stack on info.stack, which is exactly the key the transport skips.
  • logger.error("db down", { err }) loses err entirely, because an Error is an object and the tag filter keeps scalars only.
  • Cause chains (new Error(msg, { cause })) are not reconstructed, because no error object is forwarded.

Recommended pattern: keep Winston for logs, and capture exceptions directly.

try {
	await processPayment(order);
} catch (err) {
	logger.error("payment failed", { orderId: order.id }); // human-readable log line
	client.captureException(err, { tags: { orderId: order.id } }); // full stacktrace + grouping
}

captureException normalizes the Error, keeps the stacktrace, follows cause chains, and groups occurrences in the dashboard. Reach for it for anything you want to triage as an exception.

Request scope enrichment and trace correlationlink

Forwarded logs are captured through the same pipeline as captureException, so they inherit the active scope. When a framework adapter (express, koa, fastify, hapi, nest, next) opens its per-request AsyncLocalStorage scope, a logger.info(...) inside that request automatically picks up the request's method, url, route, requestId, its user (from the adapter's getUser option or setRequestUser), any setRequestTag / setRequestContext values, and the request's trace and span IDs. Trace IDs come from an inbound traceparent header when present, otherwise a fresh trace ID is generated per request, so logs and exceptions from one request correlate. If you configured traceContextProvider (for example the OpenTelemetry bridge), it is consulted per event too.

Concurrency is safe: each request has its own storage cell, so two overlapping requests never see each other's user or tags.

Outside a web framework (workers, CLIs, queue consumers), wire it up yourself:

import { installAsyncScope, runWithContext } from "@newinstance/bugwatch/node";

installAsyncScope(client);

await runWithContext({ requestId: job.id, route: "job:reindex" }, async () => {
	logger.info("job started"); // arrives tagged with requestId and route
	await reindex(job);
});

Request scope values win over anything set globally with client.setTag / client.setUser.

Complete examplelink

// src/server.ts
import express, { type Request, type Response, type NextFunction } from "express";
import winston from "winston";
import { createClient } from "@newinstance/bugwatch";
import { createBugWatchWinstonTransport } from "@newinstance/bugwatch/winston";
import {
	bugWatchExpressRequestHandler,
	bugWatchExpressErrorHandler,
} from "@newinstance/bugwatch/express";
import { setRequestTag } from "@newinstance/bugwatch/node";

if (!process.env.BUGWATCH_KEY) throw new Error("BUGWATCH_KEY is not set");

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

const logger = winston.createLogger({
	level: "info",
	transports: [
		new winston.transports.Console({ format: winston.format.simple() }),
		createBugWatchWinstonTransport(client, { level: "info" }),
	],
});

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

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

app.post("/orders", async (req: Request, res: Response, next: NextFunction) => {
	setRequestTag("plan", (req.headers["x-plan"] as string) ?? "free");
	logger.info("creating order", { itemCount: req.body.items?.length ?? 0 });
	try {
		const order = await createOrder(req.body);
		logger.info("order created", { orderId: order.id, total_cents: order.totalCents });
		res.json(order);
	} catch (err) {
		// Log line for humans, plus a real exception with the stacktrace.
		logger.error("order creation failed", { step: "createOrder" });
		client.captureException(err, { tags: { step: "createOrder" } });
		next(err);
	}
});

// Last: captures anything thrown further up the stack.
app.use(bugWatchExpressErrorHandler(client));

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

process.on("SIGTERM", async () => {
	server.close();
	await client.close(); // flushes pending events before exit
});

async function createOrder(body: unknown): Promise<{ id: string; totalCents: number }> {
	throw new Error("payment gateway timeout");
}

Troubleshootinglink

Logs never appear in the dashboard. Events are batched and flushed on an interval. In short-lived processes (scripts, serverless handlers, containers being torn down) call await client.flush() or await client.close() before exit. Turn on debug: true in createClient and watch for [bugwatch] delivery-failure diagnostics (debug mode prints failures, not successes).

Some log fields are missing. Only scalar values become tags. See the scalar-only section above and stringify nested data.

No stacktrace on an error log. Expected. The Winston transport does not forward errors or stacks. Use client.captureException(err).

Everything shows up as info. Your logger uses custom level names that are not in the mapping table, so they fall back to 30. Rename them, or capture severity-critical events with client.captureLog({ level: 50, message }).

Nothing at all is forwarded, but the console transport works. Check the transport's own level option and the logger's level. Winston filters per transport, and the more restrictive of the two wins.

401 Invalid or inactive API key in debug output. The key is revoked or from the wrong environment. Mint a fresh DSN key under BugWatch, your project, Settings, DSN keys, and confirm sk_live_... in production.