log4js

Forwarding log4js logs to BugWatchlink

The log4js adapter is an inline custom appender. Register it next to your existing appenders and every event routed to it is forwarded to BugWatch in addition to its normal output.

Installlink

npm install @newinstance/bugwatch log4js

Wiringlink

bugWatchLog4jsAppender(client) returns an object with a configure() method, which is exactly the shape log4js accepts as an inline appender module. Pass that object as the appender's type value; it is an object, not a string, so do not quote it.

import log4js from "log4js";
import { createClient } from "@newinstance/bugwatch";
import { bugWatchLog4jsAppender } from "@newinstance/bugwatch/log4js";

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

log4js.configure({
	appenders: {
		console: { type: "console" },
		bugwatch: { type: bugWatchLog4jsAppender(client) },
	},
	categories: {
		default: { appenders: ["console", "bugwatch"], level: "info" },
	},
});

const logger = log4js.getLogger();
logger.error("unhandled error in checkout flow");

The category's level is the forwarding threshold. Give a category a higher level, or omit bugwatch from its appender list, to keep chatty subsystems local.

Level mappinglink

log4js level names map onto BugWatch numeric levels:

log4jsBugWatch
TRACE10 (trace)
DEBUG20 (debug)
INFO30 (info)
WARN40 (warn)
ERROR50 (error)
FATAL60 (fatal)
MARK30 (info)

Any custom level name that is not in the table falls back to 30 (info).

Message constructionlink

log4js hands the appender an array of arguments. The adapter builds the message like this:

  1. If any arguments are strings, all of them are joined with a single space.
  2. Otherwise, if the first argument is a non-null object, it is JSON.stringify-ed.
  3. Otherwise, if the first argument is a scalar (string, number, boolean, bigint), it is String()-ified.
  4. Otherwise the message is empty.
logger.info("order", "created");           // message: "order created"
logger.info({ orderId: "o_1" });           // message: '{"orderId":"o_1"}'
logger.info("charged %d cents", 1200);     // message: "charged %d cents"

Note that log4js format placeholders are not interpolated by the adapter; the raw strings are joined as written, and non-string arguments are not folded into the message. Interpolate yourself with a template literal when the value matters.

What becomes tagslink

Exactly one tag is produced: category, set from the event's categoryName. Nothing else from the event data becomes a tag.

Workarounds for richer tagging:

  • Use a category per subsystem: log4js.getLogger("payments") gives every one of those events category: "payments", which is enough to filter on in BugWatch.
  • Put request-level dimensions on the scope: setRequestTag("orderId", id) or BugWatch.setTag("region", "eu-west-1"). These are merged into every forwarded event.
  • For a one-off event needing bespoke tags, call client.captureLog({ level, message, tags }) directly; the appender and a direct call can happily coexist.

Error handlinglink

The adapter scans the argument list for the first value that is instanceof Error and attaches it as the captured error, complete with parsed stack frames.

logger.error("charge failed", err);       // message "charge failed", err captured
logger.error(err);                        // err captured; message is JSON.stringify(err), usually "{}"
logger.error("charge failed", { err });   // Error nested in an object: NOT captured

Pass the Error as its own top-level argument. A wrapped or plain-object error fails the instanceof check, and so does an Error that crossed a realm boundary such as a worker thread or a vm context. The appender never throws back into log4js.

Request scope and trace correlationlink

log4js calls the appender synchronously from your logger.*() call, so it runs inside the AsyncLocalStorage tree opened by the framework adapter. Forwarded events therefore inherit:

  • the request user, from the adapter's getUser option or setRequestUser
  • request tags from setRequestTag and global tags from BugWatch.setTag
  • traceId / spanId from the request scope, an active withSpan, or traceContextProvider

The category tag wins over a scope tag of the same name, so avoid naming a scope tag category.

To correlate with an existing OpenTelemetry setup, wire the provider once at init:

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 log4js from "log4js";
import { createClient } from "@newinstance/bugwatch";
import { bugWatchLog4jsAppender } from "@newinstance/bugwatch/log4js";
import {
	bugWatchExpressRequestHandler,
	bugWatchExpressErrorHandler,
} from "@newinstance/bugwatch/express";
import { installNodeErrorHandlers, setRequestTag, 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);

log4js.configure({
	appenders: {
		console: { type: "console" },
		bugwatch: { type: bugWatchLog4jsAppender(client) },
	},
	categories: {
		default: { appenders: ["console"], level: "debug" },
		payments: { appenders: ["console", "bugwatch"], level: "info" },
	},
});

const log = log4js.getLogger("payments");

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);
	// Scope tags carry the dimensions the appender cannot.
	setRequestTag("orderId", orderId);

	log.info(`checkout initiated for ${orderId}`);

	try {
		await processOrder(req.body);
		res.json({ ok: true });
	} catch (err) {
		// Error as its own argument so it is captured with a stack.
		log.error("checkout failed", err);
		res.status(500).json({ ok: false });
	}
});

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

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

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

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

Events land with category: "payments", the request user, orderId from the request scope, and the active trace ids. Concurrent requests never share identity: each has its own scope cell.

Troubleshootinglink

  • No events at all. The category using the logger must list bugwatch in its appenders. A logger from getLogger("other") falls back to the default category, which may not include it.
  • type errors on configure. Pass the returned object itself, type: bugWatchLog4jsAppender(client), not a quoted string and not the function reference.
  • Events below the threshold missing. The category level filters before the appender runs, so a debug call under level: "info" never reaches BugWatch.
  • Empty messages. No string argument was passed and the first argument was not an object or a scalar. Lead with a string message.
  • Errors captured without a stack. The Error was nested inside an object or array. Pass it as a top-level argument.
  • Events lost on exit. Call log4js.shutdown() then await client.close() in your shutdown hook; the background flush runs every 5s by default.
  • Still silent. Set debug: true at init (it prints delivery failures; successes are silent), and confirm enabled is not false for the current environment.