Skip to main content

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"}
  • time is an ISO timestamp and level an uppercase label.
  • Base fields on every line: env (NODE_ENV), service (SERVICE_NAME, default @ltv/cwb) and runtime: "cloudflare-workers".
  • Workers bundles pino's browser build, which ignores pino's top-level base, formatters and timestamp options. The library passes formatters under browser, uses stdTimeFunctions.isoTime and attaches the base fields with child(), 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:

src/modules/orders/order-get.route.ts
async handle(c: Context) {
const log = getRequestLogger(c);
log.debug({orderId: 'ord_1'}, 'Loading order');
// ...
}
  • createApp stores it in c.var.cwbLogger. The variable is typed optional because plain Hono apps don't set it.
  • getRequestLogger(c) returns it, or the root logger outside createApp requests. 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.
Renamed in 0.2.0

The variable used to be c.var.logger. See the migration guide.

Levels

Output is filtered by LOG_LEVEL only, in every environment.

SettingBehavior
Unsetdebug in development (NODE_ENV of development, dev, test or local), info otherwise
LOG_LEVEL=debugEnables debug output, in production too
Valid valuesLOG_LEVELS: fatal, error, warn, info, debug, trace, silent
Case and whitespaceTrimmed and case-insensitive (INFO works)
Unknown valueOne console.warn, then the default; the Worker still starts
wrangler.jsonc
{
"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: false removes the line; getRequestLogger(c) still works.
  • logger in createApp sets the base logger for request loggers and request lines, for example createLogger({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:

KeyAcceptsOutput
errany thrown valueserializeError result: {type, message, stack?, code?, statusCode?, cause?}, cause chain at most 5 levels, cycles as [Circular]
reqa Fetch Request or a HonoRequest (c.req){method, url, headers} with sensitive query parameters and headers redacted
resany 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'}}
Don't log secrets in messages or custom fields

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:

src/logshipper.d.ts
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.