@broberg/logger
Server logging for the broberg.ai fleet. Four levels, one shape, and a credential that LOOKS like a credential is redacted before the line is written.
Read the limit before you rely on it. Redaction is pattern-based (~38 patterns via
@broberg/secret-scan): API keys, tokens, JWTs, connection strings — anything with a recognisable shape. It does not redact by field name. Measured, on the published 0.1.0:{ token: "sk-proj-AbCd…" } → "token": "[REDACTED:openai-api-key]" { gh: "ghp_AbCd…" } → "gh": "[REDACTED:github-token]" { auth: "Bearer eyJ…" } → "auth": "Bearer [REDACTED:jwt]" { password: "hemmelig123" } → "password": "hemmelig123" ← NOT redacted { apiKey: "kort" } → "apiKey": "kort" ← NOT redactedA user password, a PIN, a national ID — anything whose only marker is the key it sits under — passes straight through. Do not log a request body and assume this catches it. Key-name redaction is tracked as a gap; until it ships, pick the fields you log.
This paragraph replaced the sentence "a secret cannot reach the log by accident", which promised more than the mechanism delivers. A guarantee is exactly why someone stops checking, so an over-stated one is worse than none.
npm i @broberg/logger
import { createLogger } from "@broberg/logger";
const log = createLogger({ name: "discovery", pretty: process.env.NODE_ENV !== "production" });
log.info("served", { route: "/api/search", ms: 12 });
log.error(err, { route: "/api/search" });
const reqLog = log.child({ requestId }); // on every line, nests
Why this exists
It is not a wrapper around console.log — that would be 200 lines of
nothing and you should just use console. Two things justify a package:
- Redaction by default. Every message and every metadata value is run
through
@broberg/secret-scanbefore it leaves the process. An API key pasted into a debug line, a token in a query string, anauthorizationheader nested three levels deep in a request dump — none of them reach stdout. - One shape across every app, so a line is searchable regardless of which project emitted it.
If a change serves neither, it does not belong here.
This is not the other two log-shaped packages
| Package | Answers |
|---|---|
@broberg/logger | What was the server doing? |
@broberg/event-log | Who did what? (GDPR audit trail, business events) |
@upmetrics/sdk | What broke? (crashes, error grouping) |
API
createLogger(options) → { debug, info, warn, error, child, level }
| Option | Default | |
|---|---|---|
name | – | service name, on every line |
level | LOG_LEVEL, else info | an explicit option beats LOG_LEVEL |
pretty | false | false = one-line JSON, true = readable text |
redact | true | see the cost below before turning it off |
sink | console | where the finished line goes (injectable for tests) |
bindings | {} | fields merged into every line |
now | Date | injectable clock |
Levels are debug < info < warn < error. There is no trace or fatal: four
is enough, and every extra level is a decision every consumer then has to make.
warn/error go to stderr, the rest to stdout.
log.error(err) serialises an Error as { name, message, stack } — never
{}. Errors nested in fields are serialised too.
Redaction by field name (since 0.3.0, F063.2)
The pattern pass recognises a credential by its shape (an sk-… key, a ghp_… token, a JWT). A user's password, a PIN or a CPR number has no shape — measured on the published 0.1.0, {password:'hemmelig123'} and {apiKey:'kort'} went out verbatim. So the logger now ALSO redacts by field name, whatever the value looks like:
log.info("login", { user: { name: "ane", password: "hemmelig123" } });
// → {"…","user":{"name":"ane","password":"[REDACTED:field]"}}
- Default list (
DEFAULT_REDACT_KEYS):password,passwd,pwd,passphrase,pin,cpr,cprnr,ssn,secret,clientSecret,privateKey,authorization,cookie,set-cookie,apiKey,x-api-key,accessToken,refreshToken,idToken. - Case- and separator-insensitive:
apiKey,api_key,API-KEYare one entry.Authorizationis caught asauthorization. - Any depth, and in child bindings (
log.child({ apiKey })). - Extend it with one option — the defaults stay on:
createLogger({ redactKeys: ["memberNumber"] })(thenmember_numberis caught too). - Both passes run. Field names first, then the ~38 patterns on the serialised line. A field that is not on the list and has no secret shape is logged verbatim —
passwordChangedAtis notpassword. redact: falseturns off both.
The cost of redaction — measured, not asserted
Redaction runs ~38 regexes over each serialised line. Run npm run bench
yourself; on an M1, 50 000 calls with the sink discarded:
redact: false 1.36 µs/call 733,000 calls/s
redact: true (default) 7.79 µs/call 128,000 calls/s
overhead: 6.4 µs/call (≈5.7× the un-redacted path)
The field-name pass (0.3.0), measured as an interleaved A/B on one machine
(cb-2, Intel i5-7360U under load, 30 Oct 2026; old and new dist alternated three
times so load hits both):
0.2.3 (patterns only) 0.3.0 (field names + patterns)
redact: true 208–247 µs/call 239–260 µs/call
≈ +10 % (≈ +20 µs) on a redacted call. The absolute numbers on that machine are not comparable to the M1 table above — the same bench measured the pattern pass at ~230 µs there against 7.8 µs on the M1 in August. The M1 figures have not been re-measured since; treat them as August's, not as current.
Read that honestly: redaction is the dominant cost of a log call, and it is
still 128 000 lines/second — far more than any app in this fleet produces. Turn
it off with redact: false only if you have a measured hot path and have
decided that line cannot carry a credential. The number is here so that is a
decision, not a guess.
Hono
import { requestLogger, errorLogger } from "@broberg/logger/hono";
app.use("*", requestLogger(log, { skip: (c) => c.req.path === "/health" }));
app.onError(errorLogger(log)); // <- do not skip this, see below
One line per request: method, path, status, ms, requestId. The level
follows the status (5xx → error, 4xx → warn, else info). A request id is taken
from an incoming x-request-id if the caller sent one, otherwise generated, and
is echoed back on the response. The handler gets a request-scoped child logger
on c.get("log"), so anything it logs correlates with its request.
requestLoggeralone cannot tell you WHY a 500 happened. Hono catches a throwing handler itself and turns it into a 500 response, soawait next()resolves normally and the exception never reaches the middleware — you getstatus: 500and no reason. WireerrorLoggerasapp.onErrorto log the exception with its stack, correlated by request id. This limitation is asserted in the test suite so it cannot be quietly forgotten.
hono is an optional peer — only importing /hono pulls it in.
Guarantees
- It never throws. A broken sink, a circular reference, an object whose
toJSONthrows — all swallowed. This sits in the request path of every app, and a logger that crashes the request is strictly worse than no logger, because the app worked before you added observability. pretty: falseemits exactly one line of valid JSON per call.
Non-goals
No transports, rotation, buffering, sampling or aggregation. stdout is the contract; the platform collects it. If you find yourself adding any of those, this is the wrong package.