Logging
@ltv/cwb ships a pino logger configured for Cloudflare Workers. Every line is a JSON object written to console.*, which Workers Logs and wrangler tail pick up.
{"time":"2026-09-14T07:21:37.186Z","level":"INFO","env":"production","service":"my-api","runtime":"cloudflare-workers","requestId":"8f1c2a3b4d5e6f70-SJC","method":"GET","path":"/v1/users","status":200,"duration":3,"msg":"GET /v1/users 200 3ms"}
timeis an ISO timestamp andlevelan uppercase label.- Base fields on every line:
env(NODE_ENV),service(SERVICE_NAME, default@ltv/cwb) andruntime: "cloudflare-workers". - Workers bundles pino's browser build, which ignores pino's top-level
base,formattersandtimestampoptions. The library passes formatters underbrowser, usesstdTimeFunctions.isoTimeand attaches the base fields withchild(), so the output is the same in both builds.
Loggers
import {createLogger, getRequestLogger, logger} from '@ltv/cwb';
logger.info({userId: 'u_1'}, 'User signed in'); // root logger
const billingLog = createLogger({component: 'billing'}); // child of the root logger
Inside a request, prefer the request-scoped logger. It is a child logger bound to requestId, so every line can be correlated with the X-Request-Id header and error.id in error logs:
async handle(c: Context) {
const log = getRequestLogger(c);
log.debug({orderId: 'ord_1'}, 'Loading order');
// ...
}
createAppstores it inc.var.cwbLogger. The variable is typed optional because plain Hono apps don't set it.getRequestLogger(c)returns it, or the root logger outsidecreateApprequests. Use the function rather than reading the variable.- There is no module-global request context: concurrent requests in one isolate never mix fields.
- Error handler logs, datasource logs and cache logs all derive from the request logger and carry
requestId.
The variable used to be c.var.logger. See the migration guide.
Levels
Output is filtered by LOG_LEVEL only, in every environment.
| Setting | Behavior |
|---|---|
| Unset | debug in development (NODE_ENV of development, dev, test or local), info otherwise |
LOG_LEVEL=debug | Enables debug output, in production too |
| Valid values | LOG_LEVELS: fatal, error, warn, info, debug, trace, silent |
| Case and whitespace | Trimmed and case-insensitive (INFO works) |
| Unknown value | One console.warn, then the default; the Worker still starts |
{
"vars": {"LOG_LEVEL": "info", "SERVICE_NAME": "my-api"}
}
LOG_LEVEL is read once, when the module loads. resolveLogLevel(value, fallback) exposes the same validation.
Request log lines
createApp logs one line per request after the response: method, path, status, duration (ms), cfRay and cfColo, plus requestId.
createApp({
requestLogging: {
level: 'debug', // 'trace' | 'debug' | 'info' | 'warn', default 'info'
skip: (c) => c.req.path === '/health', // runs after the response: c.res.status is available
},
// ...
});
requestLogging: falseremoves the line;getRequestLogger(c)still works.loggerincreateAppsets the base logger for request loggers and request lines, for examplecreateLogger({app: 'billing'}).- Workers Logs bill per line, so skipping health probes can matter at volume.
Serializers and redaction
The logger's serializers handle Workers objects and never throw:
| Key | Accepts | Output |
|---|---|---|
err | any thrown value | serializeError result: {type, message, stack?, code?, statusCode?, cause?}, cause chain at most 5 levels, cycles as [Circular] |
req | a Fetch Request or a HonoRequest (c.req) | {method, url, headers} with sensitive query parameters and headers redacted |
res | any object with status / headers | {statusCode, headers} with sensitive headers redacted |
const log = getRequestLogger(c);
log.warn({req: c.req, err: error}, 'Upstream call failed');
Logging itself never throws: BigInt becomes a string, circular references become "[Circular]", and an entry that still can't be serialized is replaced by a Failed to serialize log entry line.
The redaction rules are the same as in the error handler; see Redaction. Register app-specific names once, at module scope, and they apply everywhere, including logged URLs:
import {addSensitiveHeaders, addSensitiveQueryParams} from '@ltv/cwb';
addSensitiveHeaders('x-partner-key');
addSensitiveQueryParams('invite');
The redaction helpers are exported for your own logging: redactHeaders(headers, extra?), redactQueryParams(query, extra?), redactUrl(url, extra?), isSensitiveName(name, exactNames), SENSITIVE_HEADERS, SENSITIVE_QUERY_PARAMS, SENSITIVE_NAME_FRAGMENTS and REDACTED.
import {redactUrl, serializeError} from '@ltv/cwb';
redactUrl('https://api.example.com/cb?code=abc&state=xyz');
// 'https://api.example.com/cb?code=[REDACTED]&state=xyz'
serializeError(new Error('outer', {cause: new Error('inner')}), {includeStack: false});
// {type: 'Error', message: 'outer', cause: {type: 'Error', message: 'inner'}}
Redaction applies to headers and query parameter names. Values you put in your own fields or messages, and details of errors, are logged as-is.
Log shippers
In production, every emitted line is also passed to globalThis.logshipper.log(entry) if the host defines it. A failing shipper is ignored. The library doesn't declare a global type for it; if your code references globalThis.logshipper, declare it yourself:
declare global {
var logshipper: {log(data: unknown): void} | undefined;
}
export {};
Custom pino instances
createLoggerOptions(writers?) returns the pino options the library uses, and baseLogBindings the base fields. They are useful for a separate logger with the same format:
import pino from 'pino';
import {baseLogBindings, createLoggerOptions} from '@ltv/cwb';
const auditLog = pino(createLoggerOptions()).child({...baseLogBindings, channel: 'audit'});
The optional writers replace the per-level console writers of pino's browser build (the build Workers runs), for example to capture lines in a custom sink. pino is a runtime dependency of @ltv/cwb; import it directly only if it is also a dependency of your project.