VernLLMVernLLM
Core Features

Pluggable Logger

Swap in your own logger implementation

VernLLM accepts a custom logger through the logger option. The logger must implement the Logger interface:

interface Logger {
  debug(message: string): void;
  warn(message: string): void;
  error(message: string, meta?: Record<string, unknown>): void;
}

Note debug and warn only ever receive a single message string, meta is error-only.

A logger you pass in is wrapped internally so a throwing implementation can never break the call it's describing. If your debug, warn, or error throws, the error is caught and dropped, and the operation it was logging still completes normally. An async logger (one that returns a Promise, e.g. shipping to a remote log sink) is also supported: a rejected promise is caught and dropped the same way a synchronous throw is. VernLLM never awaits your logger, so a slow logger can't add latency to the call it's describing, at most, that one log line is lost.

pluggable-logger-setup.ts
import pino from 'pino';
import type { Logger } from 'vern-llm';

const pinoInstance = pino();

const pinoLogger: Logger = {
  debug: (msg) => pinoInstance.debug(msg),
  warn: (msg) => pinoInstance.warn(msg),
  error: (msg, meta) => pinoInstance.error(meta, msg),
};

const llm = new VernLLM({
  client: fromOpenAI(openai),
  model: 'gpt-4o',
  logger: pinoLogger,
});

What gets logged, at each level

Every internal log line is prefixed [VernLLM] or [VernLLM:<requestId>] so multiple concurrent calls (and multiple VernLLM instances) stay distinguishable in one log stream:

LevelWhenExample
debugA call succeeds (output, truncated to 800 chars)[VernLLM:req_abc] output:\n{"name": "..."}
debugA call attempt fails[VernLLM:req_abc] error:\n<provider error>
debugA stream fails to open[VernLLM:req_abc] stream-open error:\n<provider error>
warnA retry is about to happen[VernLLM:req_abc] recovery attempt 2/3, waiting 840ms (honoring Retry-After)
warnUsage was spent on an attempt that then failed[VernLLM:req_abc] usage failure, attempt 2/3: type=timeout tokens=412
warnA cache adapter's get throws (treated as a miss)[VernLLM] cache read failed: <message>
warnA cache adapter's set throws (swallowed)[VernLLM] cache write failed: <message>
warnA cache adapter's delete throws (swallowed)[VernLLM] cache delete failed: <message>
warnmodel was passed but has no effect (isolateByModel off)[VernLLM] call: `model: 'x'` has no effect here...
errorA user-supplied hook throws (onUsage, onUsageFailure, onEvent, circuitBreaker.onStateChange)[VernLLM] onUsage failed with meta: { message }

error calls always pass meta as { message: string }, the caught error's .message (or 'unknown' if it wasn't an Error), never the original error object itself. If you need the original error/stack for these hook-failure logs, capture it at the hook boundary yourself, VernLLM only forwards the message string here.

Default logger behavior

If no logger is provided, VernLLM uses a console-based logger, and debug messages from it are controlled by the debug option:

  • debug: true enables debug logging.
  • debug: false disables debug logging.
  • When debug is not provided, debug logging defaults to disabled (false).

warn and error messages are always emitted regardless of the debug option.

Debug logging outputs raw response content on success (up to 800 characters per call), and the original provider error on failure. It defaults to off so response content isn't unintentionally written to logs, enable it explicitly with debug: true when you need it.

debug only controls the default console logger. If you supply a custom logger (see above), VernLLM calls its debug() method on every debug event regardless of the debug option, your logger's own implementation decides whether that call actually emits anything. A logger wrapping something like pino, which is typically gated by its own log level rather than a boolean passed in at construction, will see every debug call whether or not debug was ever set.

Redacting debug output

If prompts or responses can contain sensitive data, pass redact to scrub it before it reaches the debug logger:

redact-debug-output.ts
const llm = new VernLLM({
  client: fromOpenAI(openai),
  model: 'gpt-4o',
  debug: true,
  redact: (text) => text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED]'),
});

redact is applied at exactly two call sites internally, both debug-level:

  • The success path: the response content is passed through redact (or, for a tool-call-only turn with no text, a placeholder like [2 tool call(s)] is, since there's no text to redact) before being truncated to 800 characters and logged.
  • The failure paths: the stringified provider error is passed through redact, for both a normal call failure and a stream-open failure.

These are the one place an app has no other way to intercept: they're direct logger.debug calls, not something routed through a callback. Everything else that could carry the same content, onEvent payloads, LLMError.cause, onUsageFailure, already goes straight to a callback your own code controls, so redacting it there needs no help from VernLLM.

redact runs before every internal logger.debug() call, regardless of whether that call ends up emitting anything. With the default console logger, that means redact only has a visible effect once debug: true is set, since nothing is logged otherwise. With a custom logger, redact still runs even without debug: true: it's your logger's own debug() implementation, not the debug option, that decides whether the (already redacted) message is emitted.

On this page