New to Rune?
Start with the Overview, then use this page for detailed usage patterns.
Basic Usage
Rune is the default singleton logger instance. Use createLogger() for isolated config.
import { Rune, createLogger } from '@vielzeug/rune';
const appLog = Rune;
const apiLog = createLogger({ namespace: 'api' });
const authLog = createLogger('auth'); // shorthand namespaceEach createLogger() call is fully independent with its own transport pipeline.
The two-arg shorthand combines namespace and options cleanly:
const log = createLogger('api', { logLevel: 'warn', transports: [transport] });Transports
Transports are the delivery layer. Every LogEntry that passes the logger's level threshold is dispatched to each transport in order. Transports handle their own formatting, level filtering, and delivery.
import { createLogger } from '@vielzeug/rune';
import { consoleTransport, pipe, remoteTransport, jsonTransport } from '@vielzeug/rune';
const log = createLogger({
logLevel: 'debug',
transports: [
// Console output with CSS badges (browser) or plain text (Node)
consoleTransport({ timestamp: true }),
// Remote delivery — only errors and above
remoteTransport({
handler: async (type, data) => {
await fetch('/api/logs', { body: JSON.stringify(data), method: 'POST' });
},
level: 'error',
}),
],
});When transports is omitted, consoleTransport() is used automatically.
Built-in Transport Factories
| Factory | Use case |
|---|---|
consoleTransport() | Styled console output (default) |
remoteTransport() | HTTP/webhook delivery |
jsonTransport() | NDJSON for server-side log aggregation |
batchTransport() | Buffered delivery to reduce I/O overhead |
sampleTransport() | Probabilistic volume reduction |
redactTransport() | Sensitive field stripping before forwarding |
pipe() | Fan-out dispatcher to multiple transports |
Composing Transports
Transport factories are composable wrappers. Chain them to build a pipeline.
pipe() dispatches a single entry to multiple transports independently — an error in one transport does not prevent the others from running:
import { batchTransport, pipe, redactTransport, remoteTransport, sampleTransport } from '@vielzeug/rune';
const log = createLogger({
transports: [
consoleTransport({ level: 'debug' }),
// redact sensitive fields, sample at 10 %, batch + flush every 30 s
redactTransport({
keys: ['password', 'token'],
transport: sampleTransport({
rate: 0.1,
transport: batchTransport({
onFlush: (entries) => sendToDatadog(entries),
interval: 30_000,
}),
}),
}),
],
});Use pipe() when you want all transports to receive every entry regardless of per-transport failures:
import { pipe } from '@vielzeug/rune';
const fanout = pipe(
{ onError: (err) => console.warn('transport error', err) },
consoleTransport(),
remoteTransport({ handler, level: 'error' }),
);
const log = createLogger({ transports: [fanout] });Batch Transport Lifecycle
batchTransport starts an interval timer on first use. Call .dispose() on application shutdown to flush remaining entries and stop the timer:
const batch = batchTransport({
onFlush: (entries) => sendToCollector(entries),
interval: 10_000,
maxSize: 100,
});
// Pass batch.transport to the logger — batch itself holds flush/dispose
const log = createLogger({ transports: [batch.transport] });
// on shutdown — dispose the batch directly
process.on('exit', () => batch.dispose());batchTransport.dispose() is idempotent — calling it twice is safe and will not double-flush. [Symbol.dispose] is also available for using declarations.
WARNING
log.dispose() silences the logger but does not flush or stop batch transports. Always hold a reference to the batchTransport and call .dispose() on it explicitly at shutdown.
WARNING
After log.dispose(), the logger is silenced — all log calls (debug, info, warn, error, fatal, time, group) become no-ops. The fn callback in group() still executes, but no group header is rendered. This is intentional to prevent logging after application teardown.
Node.js: Structured JSON Logging
For server-side log pipelines (ELK, Datadog, CloudWatch), jsonTransport emits NDJSON to stdout:
import { jsonTransport } from '@vielzeug/rune';
const log = createLogger({
namespace: 'api',
transports: [jsonTransport({ level: 'info' })],
});
log.info({ path: '/users', status: 200 }, 'request');
// Outputs: {"level":"info","time":"2026-05-30T...","ns":"api","path":"/users","status":200,"msg":"request"}Configuration
Use child() to derive immutable logger variants.
const AppLog = Rune.child({
logLevel: 'warn',
namespace: 'App',
// transports inherited from Rune by default
// pass transports: [] to disable all, or transports: [...] to replace
});
// Individual getters — no config snapshot
console.log(AppLog.logLevel); // 'warn'
console.log(AppLog.namespace); // 'App'
console.log(AppLog.transports); // [...]Level threshold order: debug < info < warn < error < fatal < off
Call Signature
All log methods share a consistent three-form signature:
log.info('message'); // string only
log.error(err, 'request failed'); // Error first — auto-serialized to data.err
log.error(err, { requestId }, 'request failed'); // Error + context + message
log.info({ key: 'value' }, 'message'); // context object first, message second
log.error({ err: new Error('boom') }, 'request failed'); // Error nested in context — also auto-serialized- Error-first form: pass an
Erroras the first argument. It is automatically serialized to{ message, name, stack }under theerrkey indata. Optionally follow with aBindingsobject and/or a message string. This is the idiomatic form when the Error is the primary subject of the call. - Context-first form: pass a plain object as the first argument.
Errorvalues nested inside are also auto-serialized. Optionally follow with a message string. - String-only form: a single string message, no structured context.
The per-call context is shallow-merged with withBindings() bindings into entry.data.
Logging Methods
Rune.debug('debug details');
Rune.info({ port: 3000 }, 'server started');
Rune.warn('cache stale');
Rune.error({ err: new Error('timeout') }, 'request failed'); // Error auto-serialized in context
Rune.fatal({ service: 'db' }, 'terminating'); // above error, use for unrecoverable stateUse enabled() to avoid expensive payload construction before the level check:
if (Rune.enabled('debug')) {
Rune.debug({ diagnostics: buildLargePayload() }, 'diagnostics');
}Or use lazy() to let Rune gate it automatically:
const reqLog = Rune.withBindings({ diagnostics: lazy(() => buildLargePayload()) });
reqLog.debug('diagnostics'); // buildLargePayload() only called when debug is enabledPinned Bindings
withBindings(fields) returns a child logger where the given fields are merged into every log call. This is the idiomatic way to attach per-request or per-user context.
const api = Rune.child({ namespace: 'api' });
const reqLog = api.withBindings({ requestId: 'abc-123', userId: 42 });
reqLog.info('GET /users'); // always includes requestId and userId
reqLog.warn({ slow: true }, 'query took 2s'); // call-site fields merged inThe parent logger is not affected. Bindings stack additively through chained withBindings() calls:
const base = Rune.withBindings({ service: 'api' });
const req = base.withBindings({ requestId: 'xyz' });
// req emits both service and requestId on every callThe bindings getter returns a defensive snapshot:
console.log(reqLog.bindings); // { requestId: 'abc-123', userId: 42 }Lazy Bindings
lazy(fn) defers evaluation of a binding value until after the level check passes. The factory is never called when the entry would be suppressed.
import { lazy } from '@vielzeug/rune';
const log = Rune.withBindings({
// Only called when debug entries are emitted
snapshot: lazy(() => JSON.stringify(getFullAppState())),
// Regular values are always included as-is
service: 'api',
});
log.debug('state trace'); // snapshot() only called here
log.warn('cache miss'); // snapshot() NOT called — warn doesn't need itLazy bindings are resolved on every emitted call, not cached:
const counter = { n: 0 };
const log = Rune.withBindings({ tick: lazy(() => ++counter.n) });
log.info('a'); // tick: 1
log.info('b'); // tick: 2Child Loggers
child(overrides?) creates a new logger scoped to a namespace, level, or transport set. Use it to create module-level or service-level loggers.
const api = Rune.child({ namespace: 'api' });
const auth = api.child({ namespace: 'auth' }); // → 'api.auth' (dot-joined automatically)
api.info('GET /users');
auth.warn('token expiring');child(overrides?) clones current config and applies overrides. Transports are inherited by default.
const base = createLogger({ logLevel: 'info', namespace: 'app' });
const verbose = base.child({ logLevel: 'debug' }); // inherits transports
// Replace transports entirely on the child
const silent = base.child({ transports: [] }); // no output
// Override with a different transport set
const jsonChild = base.child({ transports: [jsonTransport()] });Child and parent configs remain independent after creation.
Timing
time(label, fn, level?) measures execution time of sync or async functions. Emits a structured entry with { duration_ms } in data and label as the message. When fn throws or rejects, the entry also includes { err } with the serialized error.
// Sync
const result = log.time('parse', () => parseDocument(input));
// Emits: { level: 'debug', message: 'parse', data: { duration_ms: 2.4 } }
// Async
const users = await log.time('db.users', () => db.query('SELECT * FROM users'));
// Emits even on rejection, with { err } included in data
// Custom level
log.time('health-check', () => ping(), 'info');
// Skipped when logLevel is 'off', but fn still executesTo forward timing data to a remote endpoint, include remoteTransport in the pipeline — debug-level entries will be forwarded at its threshold.
Groups
group(label, fn, level?) and groupCollapsed(label, fn, level?) wrap a callback in a console group, ensuring groupEnd is called even when the callback throws or rejects.
await log.groupCollapsed('Job', async () => {
await log.time('process', () => runJob());
log.info('Done');
});
// Gate the group header on a log level — suppresses when logLevel is above 'debug'
log.group(
'verbose trace',
() => {
log.debug('internal state', state);
},
'debug',
);When logLevel is 'off', the group wrapper is bypassed but the callback still executes. When a level is provided and it is below the configured threshold, the group header is skipped but the callback still runs.
Testing
Use a test transport to assert log entries without mocking console. This approach is more robust and does not require spy cleanup:
import { expect, it } from 'vitest';
import { createLogger } from '@vielzeug/rune';
import type { LogEntry, Transport } from '@vielzeug/rune';
function createTestTransport() {
const entries: LogEntry[] = [];
const transport: Transport = (entry) => entries.push(entry);
return { entries, transport };
}
it('logs errors when enabled', () => {
const { entries, transport } = createTestTransport();
const log = createLogger({ logLevel: 'error', transports: [transport] });
log.error('boom');
expect(entries).toHaveLength(1);
expect(entries[0].level).toBe('error');
expect(entries[0].message).toBe('boom');
});
it('suppresses debug when logLevel is warn', () => {
const { entries, transport } = createTestTransport();
const log = createLogger({ logLevel: 'warn', transports: [transport] });
log.debug('silent');
log.warn('loud');
expect(entries).toHaveLength(1);
});You can still spy on console methods when testing consoleTransport output directly:
import { afterEach, expect, it, vi } from 'vitest';
import { consoleTransport, createLogger } from '@vielzeug/rune';
afterEach(() => vi.restoreAllMocks());
it('writes error to console.error', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const log = createLogger({ logLevel: 'error', transports: [consoleTransport({ timestamp: false })] });
log.error('boom');
expect(spy).toHaveBeenCalled();
});Framework Integration
Rune is framework-agnostic and works as a module-level singleton or a context-injected instance.
import { createContext, useState, useContext } from 'react';
import { createLogger } from '@vielzeug/rune';
const LogContext = createContext(createLogger({ namespace: 'app' }));
function useLogger() {
return useContext(LogContext);
}
function App() {
const [requestLogger] = useState(() => createLogger({ namespace: 'app' }).withBindings({ userId: '42' }));
return (
<LogContext.Provider value={requestLogger}>
<Dashboard />
</LogContext.Provider>
);
}
function Dashboard() {
const log = useLogger();
log.info('Dashboard mounted');
return <div>Dashboard</div>;
}import { inject, provide } from 'vue';
import { createLogger, type Logger } from '@vielzeug/rune';
const LoggerKey = Symbol('logger');
function provideLogger(namespace: string) {
const logger = createLogger({ namespace });
provide(LoggerKey, logger);
return logger;
}
function useLogger(): Logger {
const logger = inject<Logger>(LoggerKey);
if (!logger) throw new Error('Logger not provided');
return logger;
}<script lang="ts">
import { setContext, getContext } from 'svelte';
import { createLogger } from '@vielzeug/rune';
const logger = createLogger({ namespace: 'app' });
setContext('logger', logger);
</script>
<!-- Child component -->
<script lang="ts">
import { getContext } from 'svelte';
import type { Logger } from '@vielzeug/rune';
const logger = getContext<Logger>('logger');
logger.info('component mounted');
</script>Pitfalls
- React: Creating the logger without a stable initializer recreates it on every re-render. Use
useState(() => createLogger(...)). - Vue 3:
inject()must be called at the top level ofsetup(), not inside callbacks. - Svelte:
getContext()must be called synchronously during component initialization.
Working with Other Vielzeug Libraries
With Courier
import { createApi } from '@vielzeug/courier';
import { createLogger } from '@vielzeug/rune';
const log = createLogger({ namespace: 'courier' });
const api = createApi({
baseUrl: 'https://api.example.com',
onError: (err) => log.error(err, 'request failed'),
});With Herald
import { createBus } from '@vielzeug/herald';
import { createLogger } from '@vielzeug/rune';
const log = createLogger({ namespace: 'bus' });
const bus = createBus<AppEvents>({
onDispatch: (event, payload) => log.debug({ event, payload }, 'dispatched'),
onError: (err, event) => log.error(err, `handler error in "${event}"`),
});Best Practices
- Create one child logger per module boundary using
Rune.child({ namespace: 'module.name' })orcreateLogger('module.name'). - Use
withBindings()to pin request/session context instead of repeating fields on each call. - Use
lazy()for expensive diagnostics bindings only needed atdebuglevel. - Set
logLevelfrom environment ('debug'in dev,'warn'or'error'in prod). - Use
enabled()before expensive payload construction thatlazy()cannot defer. - Configure transports at the application root; pass scoped loggers via DI or context.
- Keep remote handlers resilient — network failures should not block app flow.
- Call
batchTransport.dispose()on shutdown to flush remaining buffered entries. - Use
redactTransportclosest to any remote/persistent transport — never strip before console. - To style console output, pass
consoleTransport({ theme })explicitly intransports. - Use
fatal()only for genuinely unrecoverable states.