KB LabsDocs

ILogger

Last updated April 7, 2026


Structured logging interface with child loggers and optional log buffering.

ILogger is the interface every logging adapter implements. Unlike most platform interfaces, all methods are synchronous (return void, not Promise<void>) — logging is fire-and-forget, and adapters that persist logs flush asynchronously in the background.

Source of truth: platform/kb-labs-core/packages/core-platform/src/adapters/logger.ts.

Interface

TypeScript
interface ILogger {
  trace(message: string, meta?: Record<string, unknown>): void;
  debug(message: string, meta?: Record<string, unknown>): void;
  info(message: string, meta?: Record<string, unknown>): void;
  warn(message: string, meta?: Record<string, unknown>): void;
  error(message: string, error?: Error, meta?: Record<string, unknown>): void;
  fatal(message: string, error?: Error, meta?: Record<string, unknown>): void;
  child(bindings: Record<string, unknown>): ILogger;
 
  // Optional — extension points
  getLogBuffer?(): ILogBuffer | undefined;
  onLog?(callback: (record: LogRecord) => void): () => void;
}

Log levels

TypeScript
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';

Six levels. trace is the noisiest (function entry/exit, loop iterations); fatal is reserved for unrecoverable failures that terminate the process.

Log record

Every entry produced by the adapter is shaped as:

TypeScript
interface LogRecord {
  id: string;                             // ULID — sortable, compact
  timestamp: number;                      // ms since epoch
  level: LogLevel;
  message: string;
  fields: Record<string, unknown>;        // merged meta + child bindings
  source: string;                         // 'rest', 'workflow', 'cli', ...
}

All adapters must use generateLogId() from the contract package to produce IDs. It returns a ULID, which is lexicographically sortable and timestamp-based — this means log records can be sorted by ID and you get chronological order for free.

Methods

trace, debug, info, warn

Standard level methods. Each takes a message and optional metadata bag:

TypeScript
logger.info('task started', { taskId: '123' });
logger.warn('slow operation', { durationMs: 5000 });

error(message, error?, meta?) and fatal(message, error?, meta?)

Error and fatal take an optional Error object as their second argument, separate from the metadata bag. This lets the adapter extract stack traces and error codes automatically.

TypeScript
try {
  await doWork();
} catch (err) {
  logger.error('task failed', err instanceof Error ? err : undefined, { taskId: '123' });
  throw err;
}

Passing an Error is optional — you can call logger.error('something weird') with just a message if you don't have one.

child(bindings)

Returns a new logger instance with persistent context fields merged into every log entry:

TypeScript
const opLogger = logger.child({ operation: 'release', version: '1.0.0' });
opLogger.info('started');   // includes operation + version
opLogger.info('completed'); // same

Child loggers are cheap — create them liberally. Nested children merge: logger.child({ a: 1 }).child({ b: 2 }) produces entries with both fields.

Optional extensions

getLogBuffer()

If the adapter maintains an in-memory ring buffer of recent logs, this returns it:

TypeScript
interface ILogBuffer {
  append(record: LogRecord): void;
  query(query?: LogQuery): LogRecord[];
  subscribe(callback: (record: LogRecord) => void): () => void;
  getStats(): { total: number; bufferSize: number; oldestTimestamp: number | null; newestTimestamp: number | null };
}
 
interface LogQuery {
  level?: LogLevel;       // minimum level, inclusive
  source?: string;
  from?: number;          // ms
  to?: number;
  limit?: number;
}

The buffer lets Studio, REST endpoints, and CLI commands stream or page through recent logs without touching the persistent store. Not every logger has one — ConsoleLogger doesn't, the Pino adapter with streaming enabled does.

onLog(callback)

Registers a fire-and-forget callback invoked for every log record. Used by extension adapters (ring buffer, SQLite persistence) to hook into the logger without being tightly coupled to it:

TypeScript
const unsubscribe = logger.onLog((record) => {
  ringBuffer.append(record);
  persistence.write(record).catch(console.error);
});

Multiple callbacks can register at once. The logger calls them in priority order (from the extension manifest). Exceptions in callbacks are caught and logged, not propagated.

This is the hook that logRingBuffer and logPersistence extension adapters attach to — see Adapters → Overview → Extension points.

Contract rules

  • All methods return void. No promises. Adapters that persist logs batch internally and flush on a timer or on shutdown.
  • error and fatal take Error separately from meta. Callers pass the Error as the second argument so the adapter can extract stack traces.
  • child() returns a real logger, not a wrapper. The child implements the full ILogger interface.
  • IDs are ULIDs from generateLogId(). Don't invent your own — it breaks sortability guarantees.
  • Adapters are fire-and-forget. Errors inside the logger (e.g. persistence failure) must not propagate to callers.

Writing an ILogger adapter

TypeScript
import type { AdapterManifest, AdapterFactory, ILogger, LogRecord } from '@kb-labs/core-platform';
import { generateLogId } from '@kb-labs/core-platform';
 
export const manifest: AdapterManifest = {
  manifestVersion: '1.0.0',
  id: 'console-logger',
  name: 'Console Logger',
  version: '0.1.0',
  type: 'core',
  implements: 'ILogger',
  capabilities: {},
};
 
class ConsoleLogger implements ILogger {
  constructor(private bindings: Record<string, unknown> = {}, private source = 'app') {}
 
  private write(level: string, message: string, meta?: Record<string, unknown>, error?: Error) {
    const record: LogRecord = {
      id: generateLogId(),
      timestamp: Date.now(),
      level: level as any,
      message,
      fields: { ...this.bindings, ...meta, ...(error && { stack: error.stack }) },
      source: this.source,
    };
    // eslint-disable-next-line no-console
    console.log(JSON.stringify(record));
  }
 
  trace(m: string, meta?: Record<string, unknown>): void { this.write('trace', m, meta); }
  debug(m: string, meta?: Record<string, unknown>): void { this.write('debug', m, meta); }
  info(m:  string, meta?: Record<string, unknown>): void { this.write('info',  m, meta); }
  warn(m:  string, meta?: Record<string, unknown>): void { this.write('warn',  m, meta); }
  error(m: string, err?: Error, meta?: Record<string, unknown>): void { this.write('error', m, meta, err); }
  fatal(m: string, err?: Error, meta?: Record<string, unknown>): void { this.write('fatal', m, meta, err); }
 
  child(bindings: Record<string, unknown>): ILogger {
    return new ConsoleLogger({ ...this.bindings, ...bindings }, this.source);
  }
}
 
export const createAdapter: AdapterFactory<unknown, {}, ILogger> = () => new ConsoleLogger();

Real adapters (Pino) use a fast JSON serializer, support log rotation, and emit via onLog for extension hookups.

Built-in adapters implementing ILogger

PackageNotes
@kb-labs/adapters-pinoPino. Streaming support, pretty printing in dev, configurable log levels, ring buffer integration.

Extension adapters that hook into logger.onLog:

PackageNotes
@kb-labs/adapters-log-ringbufferKeeps the last N logs in memory for Studio/REST access. Priority 10.
@kb-labs/adapters-log-sqlitePersists logs to SQLite with retention policies. Priority 5. Depends on db.
ILogger — KB Labs Docs