@qianxude/logger
v0.1.12
Published
Production-ready pino-based logger implementations for qianxude stack
Readme
@qianxude/logger
Production-ready pino-based logger implementations for the qianxude stack.
Installation
bun add @qianxude/loggerOverview
This package provides:
- Factory functions to create raw pino loggers configured for common use cases
- PinoLogger class that wraps pino and implements the stack-standard
Loggerinterface from@qianxude/shared
Quick Start
Pretty Console Logger (Development)
import { createPrettyPino, PinoLogger } from '@qianxude/logger';
// Create a raw pino logger with pretty output
const rawPino = createPrettyPino({
level: 'debug',
colorize: true,
});
// Wrap it as a stack-standard Logger
const logger = new PinoLogger(rawPino, { service: 'my-app' });
logger.info('Application started');
logger.debug({ userId: 123 }, 'User logged in');
logger.error({ err: new Error('Oops') }, 'Something went wrong');Duo-Target Logger (Production)
Outputs to both console (pretty) and file (with rotation):
import { createDuoTargetPino, PinoLogger } from '@qianxude/logger';
const rawPino = createDuoTargetPino({
level: 'info',
file: './logs/app.log',
frequency: 'daily', // Rotate daily
limit: { count: 7 }, // Keep 7 days of logs
});
const logger = new PinoLogger(rawPino);
logger.info('Application started');File-Only Logger
import { createRollFilePino, PinoLogger } from '@qianxude/logger';
const rawPino = createRollFilePino({
level: 'info',
file: './logs/app.log',
size: '10m', // Rotate at 10MB
limit: { count: 5 }, // Keep 5 rotated files
});
const logger = new PinoLogger(rawPino);Logger Interface
PinoLogger implements the Logger interface from @qianxude/shared:
interface Logger {
// Log methods
log(level: LogLevel, message: string, ctx?: Context): void;
fatal(message: string, ctx?: Context): void;
error(message: string, ctx?: Context): void;
warn(message: string, ctx?: Context): void;
info(message: string, ctx?: Context): void;
debug(message: string, ctx?: Context): void;
trace(message: string, ctx?: Context): void;
// Context management
get ctx(): Context;
attach(key: string, value: any): void;
attach(ctx: Context): void;
detach(ctx: Context): void;
spawn(ctx?: Context): this;
}Context Management
// Attach context
logger.attach('userId', 123);
logger.attach({ requestId: 'abc-123', method: 'GET' });
// Create child logger with extended context
const childLogger = logger.spawn({ operation: 'query' });
childLogger.info('Executing query'); // Includes userId, requestId, method, operation
// Detach context
logger.detach({ userId: undefined });Log Levels
trace(10) - Ultra-verbose tracingdebug(20) - Debugging informationinfo(30) - General information (default)warn(40) - Warningserror(50) - Errorsfatal(60) - Critical errors
Factory Functions
createPrettyPino(options)
Creates a pino logger with pretty console output.
interface PrettyPinoOptions {
level?: LogLevel; // Default: 'info'
colorize?: boolean; // Default: true
translateTime?: string; // Default: 'SYS:standard'
prettyOptions?: pino.PrettyOptions;
}createDuoTargetPino(options)
Creates a pino logger that outputs to both console (pretty) and file (with rotation).
interface DuoTargetPinoOptions {
level?: LogLevel; // Default: 'info'
file: string; // Required: log file path
frequency?: 'daily' | 'hourly' | number; // Time-based rotation
size?: string | number; // Size-based rotation (e.g., '10m')
mkdir?: boolean; // Create parent dirs (default: true)
symlink?: boolean; // Create symlink to current log (default: true)
limit?: { count: number }; // Number of rotated files to keep
colorize?: boolean; // Console colorize (default: true)
translateTime?: string; // Default: 'SYS:standard'
}createRollFilePino(options)
Creates a pino logger that outputs only to file (with rotation).
interface RotationOptions {
level?: LogLevel;
file: string; // Required
frequency?: 'daily' | 'hourly' | number;
size?: string | number;
mkdir?: boolean;
symlink?: boolean;
limit?: { count: number };
}createPino(options)
Creates a basic pino logger with custom options.
const logger = createPino({
level: 'debug',
base: { service: 'my-service' },
});Environment Variables
The logger package supports configuration via environment variables. Use the parsing functions to convert environment variables into logger options.
Required vs Optional
| Variable | Required For | Optional For | Default |
|----------|--------------|--------------|---------|
| LOG_FILE | parsePinoRollOptionsEnv, parseDuoTargetPinoOptionsEnv | parsePrettyPinoOptionsEnv | - |
| LOG_LEVEL | - | All functions | 'info' |
| LOG_COLORIZE | - | All functions | true |
| LOG_TRANSLATE_TIME | - | All functions | 'SYS:standard' |
| LOG_FILE_SIZE | - | File logging | - |
| LOG_FILE_FREQUENCY | - | File logging | - |
| LOG_FILE_EXTENSION | - | File logging | - |
| LOG_FILE_SYMLINK | - | File logging | true |
| LOG_FILE_LIMIT_COUNT | - | File logging | - |
| LOG_FILE_DATE_FORMAT | - | File logging | - |
| LOG_FILE_MKDIR | - | File logging | true |
Important: LOG_FILE is the only required environment variable, and it's only required for file-based logging functions (parsePinoRollOptionsEnv and parseDuoTargetPinoOptionsEnv).
Available Environment Variables
Common/Pretty Options (All Optional)
| Variable | Description | Example Values |
|----------|-------------|----------------|
| LOG_LEVEL | Minimum log level | fatal, error, warn, info, debug, trace |
| LOG_COLORIZE | Enable colorized output | true, false |
| LOG_TRANSLATE_TIME | Time format string | SYS:standard, SYS:iso |
File Roll Options
| Variable | Description | Required | Example Values |
|----------|-------------|----------|----------------|
| LOG_FILE | Log file path | Yes (for file logging) | ./logs/app.log |
| LOG_FILE_SIZE | Max file size | No | 10m, 100k, 1g, or number (MB) |
| LOG_FILE_FREQUENCY | Rotation frequency | No | daily, hourly, or number (ms) |
| LOG_FILE_EXTENSION | File extension to append | No | .log |
| LOG_FILE_SYMLINK | Create symlink to current log | No | true, false |
| LOG_FILE_LIMIT_COUNT | Number of rotated files to keep | No | 7 |
| LOG_FILE_DATE_FORMAT | Date format for filename | No | yyyy-MM-dd |
| LOG_FILE_MKDIR | Create parent directories | No | true, false |
Usage Examples
Parse Pretty Logger Options
import { parsePrettyPinoOptionsEnv, createPrettyPino } from '@qianxude/logger';
// Parse from process.env
const options = parsePrettyPinoOptionsEnv();
// Or pass a custom env object
const options = parsePrettyPinoOptionsEnv({
LOG_LEVEL: 'debug',
LOG_COLORIZE: 'true',
});
const logger = createPrettyPino(options);Parse File Roll Options
import { parsePinoRollOptionsEnv, createRollFilePino } from '@qianxude/logger';
const options = parsePinoRollOptionsEnv({
LOG_FILE: './logs/app.log',
LOG_FILE_FREQUENCY: 'daily',
LOG_FILE_LIMIT_COUNT: '7',
});
if (options.file) {
const logger = createRollFilePino({ level: 'info', ...options });
}Parse Duo-Target Options
import { parseDuoTargetPinoOptionsEnv, createDuoTargetPino } from '@qianxude/logger';
const options = parseDuoTargetPinoOptionsEnv(process.env);
if (options.file) {
const logger = createDuoTargetPino(options);
}Environment Variable Constants
Use the exported LOG_ENV_VARS constant for type-safe env var names:
import { LOG_ENV_VARS } from '@qianxude/logger';
console.log(LOG_ENV_VARS.LOG_LEVEL); // 'LOG_LEVEL'
console.log(LOG_ENV_VARS.LOG_FILE); // 'LOG_FILE'Error Handling
The parsing functions throw descriptive errors for invalid values:
import { parsePrettyPinoOptionsEnv } from '@qianxude/logger';
try {
parsePrettyPinoOptionsEnv({ LOG_LEVEL: 'verbose' });
} catch (err) {
// Error: Invalid LOG_LEVEL value 'verbose'. Must be one of: fatal, error, warn, info, debug, trace.
}Graceful Error Handling with noThrow
When you want to handle missing required env vars gracefully without exceptions, use the noThrow option:
import { parsePinoRollOptionsEnv, createRollFilePino } from '@qianxude/logger';
const options = parsePinoRollOptionsEnv(process.env, { noThrow: true });
if (options._invalid) {
console.warn('File logging disabled:', options._invalidVars?.join(', '));
// Fall back to console-only logging
} else {
const logger = createRollFilePino({ level: 'info', ...options });
}This is useful when file logging is optional and you want to check at runtime whether it's available.
import { parseDuoTargetPinoOptionsEnv, createDuoTargetPino, createPrettyPino } from '@qianxude/logger';
const options = parseDuoTargetPinoOptionsEnv(process.env, { noThrow: true });
// Use duo-target if LOG_FILE is set, otherwise fall back to pretty console
const logger = options._invalid
? createPrettyPino({ level: 'info' })
: createDuoTargetPino(options);Integration with @qianxude/shared
Use with the shared logger:
import { logger } from '@qianxude/shared';
import { createDuoTargetPino, PinoLogger } from '@qianxude/logger';
// At app startup, configure the shared logger
const pinoLogger = new PinoLogger(createDuoTargetPino({
level: 'info',
file: './logs/app.log',
frequency: 'daily',
}));
// Use configureLogger to set the delegate
import { configureLogger } from '@qianxude/shared';
configureLogger(logger, pinoLogger);
// Now all packages using the shared logger will use pino
logger.info('Application initialized');LLM Call Lifecycle Logging
@qianxude/logger includes an opt-in, file-backed LLM lifecycle controller. It does not modify
@qianxude/shared and does not intercept ordinary Logger calls.
Resolve the deployment configuration first, then pass the resolved options to the controller:
import { logger } from '@qianxude/shared';
import {
createLLMCallLifecycle,
parseLLMCallLifecycleOptionsEnv,
} from '@qianxude/logger';
const options = parseLLMCallLifecycleOptionsEnv();
const llm = createLLMCallLifecycle(logger, options);The controller supports an already-completed call:
await llm.record({
request: {
headers: {
'content-type': 'application/json',
},
body: {
model: 'gpt-4o',
},
},
response: {
content: 'Hello',
},
task: 'chat/title-generation',
});For accurate lifecycle timing, use begin():
const call = llm.begin({
request,
task: 'chat/title-generation',
});
const response = await invokeModel(request);
await call.complete(response);Model and query
The effective model is resolved from a first-class model parameter, falling back to
request.body.model. A caller-supplied model overrides the body value.
const call = llm.begin({
request,
model: 'gpt-4o-mini', // overrides request.body.model when present
});A human-readable query is captured in _meta for log scannability. When omitted, it is derived
from the content of the last role: 'user' message in request.body.messages (handling both
string content and array text-block content), normalized and truncated to 500 characters.
await llm.record({
request,
response: { content: 'Hello' },
query: 'Summarize the meeting', // optional, otherwise derived from the last user message
});Failure and cancellation
A non-stream failure that happens between begin() and complete() produces no record unless you
finalize it explicitly. Use error() to record a failed call, or abort() to record a cancellation:
const call = llm.begin({ request, task: 'completion' });
try {
const response = await invokeModel(request);
await call.complete(response);
} catch (error) {
await call.error(error); // records error metadata, does not rethrow
}
// Or, for a timeout / client disconnect:
await call.abort('timeout');Each handle finalizes exactly once; calling a second method afterwards throws. Streams already
record errors and partial chunks in their own teardown, so error()/abort() are intended for
non-stream lifecycle handles.
Durability: flush() and shutdown()
File writes are fire-and-forget: record(), complete(), error(), abort(), and stream()
resolve once the write is scheduled, not necessarily after it lands. Call flush() (or its alias
shutdown()) before a clean process exit to await all pending writes:
await llm.flush(); // await all in-flight file writes
// or
await llm.shutdown();Bounded stream buffering
By default stream() buffers every yielded chunk in memory. Set maxBufferedChunks to keep only
the newest N chunks on a rolling basis; older chunks are dropped and _meta.truncated is set to
true so analysts know data was elided:
const llm = createLLMCallLifecycle(logger, {
...options,
maxBufferedChunks: 100, // 0 = unbounded (default)
});Streaming calls are buffered until completion and re-yielded unchanged. On stream end, the raw chunks are
also aggregated into a single response object (see Stream response aggregation):
const call = llm.begin({ request, task: 'chat/title-generation' });
for await (const chunk of call.stream(modelStream)) {
consume(chunk);
}Each enabled call produces one JSON file. The record contains request, response or top-level
chunks, and a final _meta property with lightweight lifecycle metadata. _meta includes an
outcome field (success, error, or aborted) so batch analysis can filter records without
testing for the presence of specific payload fields:
{
"id": "...",
"request": {
"headers": {},
"body": { "model": "gpt-4o" }
},
"response": { "text": "Hello" },
"_meta": {
"timestamp": "...",
"startedAt": "...",
"finishedAt": "...",
"durationMs": 123,
"task": "chat/title-generation",
"query": "Summarize the meeting",
"model": "gpt-4o",
"stream": false,
"outcome": "success"
}
}The truncated flag is only written (as true) when a bounded stream buffer dropped older chunks.
Stream response aggregation
When a stream completes, the buffered raw chunks are kept and folded into a single aggregated
response object written to the record. This mirrors the non-stream response contract so batch
analysis can treat stream and non-stream records uniformly via response:
{
"request": { "body": { "model": "qwen3.6-27b" } },
"chunks": [
{ "type": "start" },
{ "type": "reasoning-delta", "text": "The user is" },
{ "type": "text-delta", "text": "Hello world" },
{ "type": "finish", "finishReason": "stop", "totalUsage": { "inputTokens": 546, "outputTokens": 66, "totalTokens": 612 } }
],
"response": {
"text": "Hello world",
"reasoning": "The user is",
"finishReason": "stop",
"usage": { "inputTokens": 546, "outputTokens": 66, "totalTokens": 612 },
"model": "Qwen3.6-27B"
},
"_meta": { "stream": true, "outcome": "success" }
}Aggregation reads chunk fields generically and is designed for AI-SDK-style typed stream parts, so the logger stays decoupled from any provider protocol. The following parts are aggregated:
| Part type | Aggregates to |
| --- | --- |
| text-delta | Concatenated into response.text |
| reasoning-delta | Concatenated into response.reasoning |
| finish-step | usage (preferred) and model from response.modelId |
| finish | totalUsage (fallback) and finishReason |
| plain chunk { content } / { text } | Concatenated into response.text |
Usage is normalized to { inputTokens, outputTokens, totalTokens }; when a finish-step chunk carries
a nested raw payload (e.g. prompt_tokens / completion_tokens / total_tokens), it is preserved
as usage.raw. finishReason prefers the terminal finish part and falls back to finish-step.
response is omitted (and only chunks written) when a stream exposes no text, reasoning, usage, or
model — e.g. control or tool-only streams. Truncation still applies to the buffered chunks; the
aggregated response is built from the retained chunks and the _meta.truncated flag is set so
consumers know the result may be partial.
The logger does not calculate usage, token counts, cost, or TPS on its own. When a provider
includes usage on the terminal stream parts, it is captured as-is; otherwise usage is absent. Batch
analysis should still handle missing or partial usage.
LLM Call Environment Variables
The switch and output directory are deployment-controlled:
| Variable | Description | Default |
| --- | --- | --- |
| LOG_LLM_CALL_ENABLED | Enable or disable LLM call files | false when unset |
| LOG_LLM_CALL_DIR | Output directory; required when enabled | unset |
| LOG_LLM_CALL_LATEST | symlink or none for the latest pointer | symlink |
| LOG_LLM_CALL_MAX_BUFFERED | Max buffered chunks per stream (0 = unbounded) | unbounded |
When LOG_LLM_CALL_ENABLED=true and LOG_LLM_CALL_DIR is missing or empty,
parseLLMCallLifecycleOptionsEnv() throws immediately:
LLM call logging is enabled, but LOG_LLM_CALL_DIR is not configured.
Set LOG_LLM_CALL_DIR to a writable directory, or set
LOG_LLM_CALL_ENABLED=false to disable LLM call logging.When disabled, record() and complete() are no-ops, and stream() directly passes through the
source iterator without buffering or creating files.
Request headers are recorded as supplied. Callers should redact sensitive values such as
Authorization before passing requests to the controller.
License
MIT
