Write useful, safe logs
Record what happened with enough context to investigate it, without copying secrets into your logs.
On this page
Source-backed MarkdownCapture a note workflow
Complete Installation and copy this lab. It uses the real Pino driver and a writable stream, with devtools delivery explicitly disabled. No running log server is required.
Application code normally uses app().log. The example owns its logger so capture, flush and shutdown are testable in isolation.
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/logging/examples/. examples/Run and inspect three records
Expect Note saved at level 30, Summary failed at level 50 and Diagnostics enabled at level 20. The initial debug record is filtered. Password, token and the custom provider key are absent.
The output removes timestamps, process metadata and error stacks for readability. Real production records retain that diagnostic metadata. Only synthetic test secrets are used.
npx tsx examples/runNoteLogs.tsUse fields and a short message
Pass a structured object first and a short message second: log.info({ noteId }, "Note saved"). Use a child logger for stable request/job/owner context shared by several records.
Pass an Error under err to retain its type, message, stack and cause. A failure log does not repair the operation or change its HTTP response; the application still owns recovery.
import { Writable } from 'node:stream';
import { pathToFileURL } from 'node:url';
import { Log, PinoLoggerDriver } from '@db3.ai/app/logging';
/**
* Captures real structured logs and demonstrates child context and redaction.
*
* @returns Parsed records without unstable process/time fields for readable output.
*/
export async function runNoteLogs() {
const chunks: string[] = [];
const destination = new Writable({
/** Captures complete Pino output using a real writable stream. */
write(chunk, _encoding, callback) { chunks.push(chunk.toString()); callback(); },
});
const log = new Log({ driver: new PinoLoggerDriver({ level: 'info', environment: 'test', source: 'notes', console: false, devtools: false, redact: ['integration.apiKey'] }, destination) });
try {
const request = log.child({ requestId: 'request-1', ownerId: 'ada' });
request.debug('Filtered diagnostic');
request.info({ noteId: 'one', password: 'test-password', integration: { apiKey: 'test-provider-key' } }, 'Note saved');
request.error({ err: new Error('Summary unavailable'), token: 'test-token' }, 'Summary failed');
log.level = 'debug';
log.debug({ component: 'notes' }, 'Diagnostics enabled');
log.level = 'silent';
log.error('Filtered after disabling');
await log.flush();
return chunks.join('').trim().split('\n').map(line => {
const { time, pid, hostname, ...record } = JSON.parse(line);
if (record.err) delete record.err.stack;
return record;
});
} finally {
await log.close();
destination.end();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runNoteLogs(), null, 2));
Control volume
Levels are trace, debug, info, warn, error, fatal and silent. Default output starts at info, or silent in tests. Supply an explicit level for deterministic tests.
log.level reads/changes the root threshold. Use enabled: false to disable creation-time logging and console: false to disable stdout. A child shares destinations but is not a separately owned lifecycle resource.
Configure the app logger
Pass log: { level: "info", source: "notes-api", devtools: false, bindings: { release }, redact: ["integration.apiKey"] } in App options. These are AppOptions.log, not arbitrary values under config.log.
Existing environment names remain PLATFORM_LOG_LEVEL, PLATFORM_LOG_SOURCE, PLATFORM_LOG_DEVTOOLS, PLATFORM_DEVTOOLS_EVENTS_URL, PLATFORM_DEVTOOLS_HOST and PLATFORM_DEVTOOLS_API_PORT. The public package name does not rename environment variables.
The default destination is newline-delimited JSON on stdout. Interactive development can automatically enable devtools; explicitly disable it when a script/test must not make HTTP requests.
Redaction is a backstop
Defaults remove common password, secret, token, authorization and cookie paths. Custom redact paths extend those defaults; redact: false disables them.
This is path-based removal, not a scanner for arbitrary sensitive text. A key inside a message string, deep unfamiliar field, URL query or Error message may still be logged. Select safe fields, avoid request bodies and test your own paths.
Do not send production credentials or customer data to a development ingestion endpoint. Logging is not an audit ledger or secure secret store.
Flush and close at shutdown
flush() waits for accepted records to reach the driver destination; close() flushes and ends an owned worker transport. App.close() owns this for the app logger. Avoid process.exit() immediately after logging.
Devtools delivery is bounded and best-effort: by default 25 records per batch, 100 ms partial-batch delay and up to 1,000 pending events. Failed batches can be discarded; later delivery pauses. Flush is not proof of durable remote ingestion.
A manually supplied stream belongs to its creator. This example closes Log then ends the stream. Do not close a shared app logger at the end of each request.
Testing
Create a tests/logging directory and save the test below as runNoteLogs.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.
Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.
import { expect, it } from 'vitest';
import { runNoteLogs } from '../../examples/runNoteLogs';
it('preserves structured context and errors while filtering levels and removing selected secrets', async () => {
const records = await runNoteLogs();
expect(records).toHaveLength(3);
expect(records[0]).toMatchObject({ source: 'notes', environment: 'test', requestId: 'request-1', ownerId: 'ada', noteId: 'one', msg: 'Note saved', level: 30, integration: {} });
expect(records[1]).toMatchObject({ msg: 'Summary failed', level: 50, err: { type: 'Error', message: 'Summary unavailable' } });
expect(records[2]).toMatchObject({ msg: 'Diagnostics enabled', level: 20 });
expect(JSON.stringify(records)).not.toMatch(/test-password|test-provider-key|test-token|Filtered diagnostic|Filtered after disabling/);
});
Assert records, not console formatting
The copied test asserts context, levels, error fields and the absence of three synthetic secrets using the real driver. Add a sensitive field used by your own app and prove its path is removed.
The example writes a controlled failure record then enables diagnostics. It does not claim to recover a failed provider request or prove remote devtools delivery.
npx vitest run tests/logging/runNoteLogs.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.tsCoverage and limits
Taught/tested: structured records, child context, errors, threshold filtering/change, default/custom redaction, flush and direct-stream close. App shutdown and controlled devtools batching/failure have separate service tests.
Remote ingestion, retention, indexing, alerting and compliance/audit policy are application/deployment responsibilities. Logs must not trigger business behavior.
Captures real Pino records, tests child context and level filtering, and checks default/custom secret redaction.
The guide test passes against the real framework components.packages/app/src/logging/tests/examples/runNoteLogs.test.tsThis test command requires the framework repository. Use the walkthrough commands in an installed application.
Environment: Node.js 24. Real writable stream and Pino; development HTTP delivery disabled.