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 log4jsWiringlink
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:
| log4js | BugWatch |
|---|---|
TRACE | 10 (trace) |
DEBUG | 20 (debug) |
INFO | 30 (info) |
WARN | 40 (warn) |
ERROR | 50 (error) |
FATAL | 60 (fatal) |
MARK | 30 (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:
- If any arguments are strings, all of them are joined with a single space.
- Otherwise, if the first argument is a non-null object, it is
JSON.stringify-ed. - Otherwise, if the first argument is a scalar (
string,number,boolean,bigint), it isString()-ified. - 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 eventscategory: "payments", which is enough to filter on in BugWatch. - Put request-level dimensions on the scope:
setRequestTag("orderId", id)orBugWatch.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 capturedPass 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
getUseroption orsetRequestUser - request tags from
setRequestTagand global tags fromBugWatch.setTag traceId/spanIdfrom the request scope, an activewithSpan, ortraceContextProvider
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
bugwatchin itsappenders. A logger fromgetLogger("other")falls back to thedefaultcategory, which may not include it. typeerrors 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
levelfilters before the appender runs, so adebugcall underlevel: "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
Errorwas nested inside an object or array. Pass it as a top-level argument. - Events lost on exit. Call
log4js.shutdown()thenawait client.close()in your shutdown hook; the background flush runs every 5s by default. - Still silent. Set
debug: trueat init (it prints delivery failures; successes are silent), and confirmenabledis notfalsefor the current environment.