/** * Unified Logger - Consolidated logging for all postgres.do packages * * This module provides a unified logging interface that replaces multiple * logger implementations across the codebase with a single, consistent API. * * ## Replaced Implementations * * - `CDCLogger` from `packages/postgres.do/src/cdc/logger.ts` * - `Logger` interface from `packages/postgres/src/storage/tiered-orchestrator.ts` * - `TenantRouterLogger` from `packages/postgres/src/routing/tenant-router.ts` * * ## Features * * - Multiple log levels (DEBUG, INFO, WARN, ERROR, SILENT) * - Structured logging with arbitrary metadata * - Child loggers with scoped context * - Log filtering by level * - Custom handlers/formatters (Console, JSON, or custom) * - Event capture for debugging with circular buffer * - Cloudflare Workers compatible (no Node.js dependencies) * * ## Quick Start * * ```typescript * import { createLogger, LogLevel } from '@dotdo/postgres-shared' * * // Create a basic logger * const logger = createLogger() * logger.info('Hello world') * * // Create a logger with configuration * const debugLogger = createLogger({ * level: LogLevel.DEBUG, * prefix: '[MyService]', * timestamps: true, * }) * * // Log with context * logger.info('User action', { userId: '123', action: 'login' }) * * // Create a child logger with scoped context * const requestLogger = logger.child({ requestId: 'abc-123' }) * requestLogger.info('Processing request') // includes requestId in context * ``` * * ## Migration Guide * * ### From CDCLogger * * ```typescript * // Before (CDCLogger) * const logger = new CDCLogger({ level: LogLevel.DEBUG, prefix: '[CDC]' }) * logger.info('Event', { subscriptionId: 'sub-123', data: { key: 'value' } }) * const subLogger = logger.withSubscription('sub-123') * * // After (Unified Logger) * const logger = createLogger({ level: LogLevel.DEBUG, prefix: '[CDC]' }) * logger.info('Event', { subscriptionId: 'sub-123', key: 'value' }) * const subLogger = logger.child({ subscriptionId: 'sub-123' }) * ``` * * ### From TieredOrchestrator Logger interface * * The unified logger is directly compatible with the simple Logger interface: * * ```typescript * // The simple interface from tiered-orchestrator: * // interface Logger { * // debug: (msg: string) => void * // info: (msg: string) => void * // warn: (msg: string) => void * // error: (msg: string) => void * // } * * // Just use createLogger() - it implements the same interface! * const logger = createLogger() * orchestrator.setLogger(logger) // Works directly! * ``` * * ### From TenantRouterLogger * * ```typescript * // Before (TenantRouterLogger) * logger.log({ level: 'info', message: 'Request', tenantId: 'acme' }) * * // After (Unified Logger) * logger.log({ level: LogLevel.INFO, message: 'Request' }) * // Or use the simpler API: * logger.info('Request', { tenantId: 'acme' }) * ``` * * @module logger */ /** * Log levels in order of verbosity (lower = more verbose) * * @example * ```typescript * // Set logger to only show warnings and errors * logger.setLevel(LogLevel.WARN) * * // Disable all logging * logger.setLevel(LogLevel.SILENT) * ``` */ export enum LogLevel { /** Detailed debugging information */ DEBUG = 0, /** General informational messages */ INFO = 1, /** Warning messages for potentially problematic situations */ WARN = 2, /** Error messages for serious problems */ ERROR = 3, /** No logging at all */ SILENT = 4, } /** * Log entry structure - unified from all implementations */ export interface LogEntry { /** Log level */ level: LogLevel /** Human-readable level name */ levelName: string /** Log message */ message: string /** ISO timestamp (empty string if timestamps disabled) */ timestamp: string /** Additional context data */ context?: Record /** Error if this is an error log */ error?: Error } /** * Custom log handler function type * * Implement this to create custom log destinations (e.g., remote logging, * file logging, etc.) * * @example * ```typescript * const remoteHandler: LogHandler = (entry) => { * fetch('/logs', { * method: 'POST', * body: JSON.stringify(entry), * }) * } * ``` */ export type LogHandler = (entry: LogEntry) => void /** * Extended handler interface with additional methods * Used by MultiHandler and BatchHandler to expose management APIs */ export interface MultiLogHandler extends LogHandler { addHandler(handler: LogHandler): void removeHandler(handler: LogHandler): void } export interface BatchLogHandler extends LogHandler { flush(): void } /** * Logger configuration */ export interface LoggerConfig { /** Minimum level to log (default: INFO) */ level?: LogLevel /** Custom log handler (default: console) */ handler?: LogHandler /** Whether to include timestamps (default: true) */ timestamps?: boolean /** Prefix for all log messages */ prefix?: string /** Whether to capture logs in memory for debugging (default: false) */ captureEvents?: boolean /** Maximum number of events to capture in memory (default: 100) */ maxCapturedEvents?: number } /** * Log context - arbitrary key-value pairs for structured logging * * Common context keys: * - `tenantId` - For multi-tenant applications * - `subscriptionId` - For CDC subscriptions * - `correlationId` - For distributed tracing * - `requestId` - For request tracking * - `error` - Error object (will be extracted automatically) */ export type LogContext = Record /** * Logger interface - the unified logger contract */ export interface ILogger { debug(message: string, context?: LogContext): void info(message: string, context?: LogContext): void warn(message: string, context?: LogContext): void error(message: string, context?: LogContext): void log(entry: Partial): void child(context: LogContext): ILogger getLevel(): LogLevel setLevel(level: LogLevel): void setHandler(handler: LogHandler): void enableCapture(maxEvents?: number): void disableCapture(): void getCapturedLogs(): LogEntry[] clearCapturedLogs(): void } /** * Level name mapping */ const LEVEL_NAMES: Record = { [LogLevel.DEBUG]: 'DEBUG', [LogLevel.INFO]: 'INFO', [LogLevel.WARN]: 'WARN', [LogLevel.ERROR]: 'ERROR', [LogLevel.SILENT]: 'SILENT', } /** * Circular buffer for event log capture * Memory-efficient storage with automatic eviction of oldest entries */ class CircularBuffer { private buffer: T[] = [] private head = 0 private size = 0 constructor(private readonly capacity: number) {} push(item: T): void { if (this.size < this.capacity) { this.buffer.push(item) this.size++ } else { this.buffer[this.head] = item } this.head = (this.head + 1) % this.capacity } toArray(): T[] { if (this.size < this.capacity) { return [...this.buffer] } // Return items in order from oldest to newest return [...this.buffer.slice(this.head), ...this.buffer.slice(0, this.head)] } clear(): void { this.buffer = [] this.head = 0 this.size = 0 } get length(): number { return this.size } } /** * Detect if we're in a TTY environment (Workers-compatible) */ function detectTTY(): boolean { // Workers doesn't have process.stdout.isTTY // Check safely without throwing try { if (typeof process !== 'undefined' && process?.stdout?.isTTY) { return true } } catch { // Ignore - not in Node.js environment } return false } /** * Default console handler with color support */ function createDefaultConsoleHandler(useColors: boolean): LogHandler { const colors = { [LogLevel.DEBUG]: '\x1b[90m', // Gray [LogLevel.INFO]: '\x1b[36m', // Cyan [LogLevel.WARN]: '\x1b[33m', // Yellow [LogLevel.ERROR]: '\x1b[31m', // Red [LogLevel.SILENT]: '', } const reset = '\x1b[0m' return (entry: LogEntry) => { const parts: string[] = [] if (useColors) { parts.push(colors[entry.level]) } parts.push(`[${entry.levelName}]`) if (entry.timestamp) { parts.push(`[${entry.timestamp}]`) } parts.push(entry.message) if (useColors) { parts.push(reset) } const message = parts.join(' ') switch (entry.level) { case LogLevel.DEBUG: console.debug(message, entry.context || '') break case LogLevel.INFO: console.info(message, entry.context || '') break case LogLevel.WARN: console.warn(message, entry.context || '') break case LogLevel.ERROR: console.error(message, entry.context || '', entry.error || '') break } } } /** * Console handler class with configurable color support * @deprecated Use ConsoleHandler factory function instead for better compatibility */ export class ConsoleHandlerClass { private handler: LogHandler constructor(options?: { colors?: boolean | 'auto' }) { const colors = options?.colors const useColors = colors === 'auto' ? detectTTY() : colors === true this.handler = createDefaultConsoleHandler(useColors) } /** * Make the instance callable as a LogHandler */ call(entry: LogEntry): void { this.handler(entry) } /** * Get the underlying handler function */ toHandler(): LogHandler { return this.handler } } /** * Console handler factory - creates a LogHandler for console output * * @param options.colors - Color mode: true (always), false (never), 'auto' (detect TTY) * @returns LogHandler function * * @example * ```typescript * // With colors * const logger = createLogger({ * handler: new ConsoleHandler({ colors: true }) * }) * * // Auto-detect (colors in terminal, plain in Workers) * const logger = createLogger({ * handler: new ConsoleHandler({ colors: 'auto' }) * }) * ``` */ export const ConsoleHandler = function (options?: { colors?: boolean | 'auto' }): LogHandler { const colors = options?.colors const useColors = colors === 'auto' ? detectTTY() : colors === true return createDefaultConsoleHandler(useColors) } as unknown as new (options?: { colors?: boolean | 'auto' }) => LogHandler /** * JSON handler class for structured output */ export class JsonHandlerClass { private outputFn: (line: string) => void constructor(options?: { output?: (line: string) => void }) { this.outputFn = options?.output ?? ((line: string) => console.log(line)) } /** * Handle a log entry by outputting it as JSON */ call(entry: LogEntry): void { const output = { level: entry.level, levelName: entry.levelName, message: entry.message, timestamp: entry.timestamp, context: entry.context, error: entry.error ? { name: entry.error.name, message: entry.error.message, stack: entry.error.stack, } : undefined, } this.outputFn(JSON.stringify(output)) } /** * Get the underlying handler function */ toHandler(): LogHandler { return (entry: LogEntry) => this.call(entry) } } /** * JSON handler factory - creates a LogHandler for JSON-formatted output * * Useful for structured logging to log aggregation services. * * @param options.output - Custom output function (default: console.log) * @returns LogHandler function * * @example * ```typescript * // Default (outputs to console.log) * const logger = createLogger({ * handler: new JsonHandler() * }) * * // Custom output (e.g., to a buffer) * const logs: string[] = [] * const logger = createLogger({ * handler: new JsonHandler({ output: (line) => logs.push(line) }) * }) * ``` */ export const JsonHandler = function (options?: { output?: (line: string) => void }): LogHandler { const outputFn = options?.output ?? ((line: string) => console.log(line)) return (entry: LogEntry) => { const output = { level: entry.level, levelName: entry.levelName, message: entry.message, timestamp: entry.timestamp, context: entry.context, error: entry.error ? { name: entry.error.name, message: entry.error.message, stack: entry.error.stack, } : undefined, } outputFn(JSON.stringify(output)) } } as unknown as new (options?: { output?: (line: string) => void }) => LogHandler /** * Unified Logger class */ export class Logger implements ILogger { private level: LogLevel private handler: LogHandler private timestamps: boolean private prefix: string private capturedEvents: CircularBuffer | null = null private frozenCapture: LogEntry[] | null = null private baseContext: LogContext constructor(config: LoggerConfig = {}, baseContext: LogContext = {}) { this.level = config.level ?? LogLevel.INFO this.handler = config.handler ?? createDefaultConsoleHandler(detectTTY()) this.timestamps = config.timestamps ?? true this.prefix = config.prefix ?? '' this.baseContext = baseContext if (config.captureEvents) { this.capturedEvents = new CircularBuffer(config.maxCapturedEvents ?? 100) } } /** * Set the log level */ setLevel(level: LogLevel): void { this.level = level } /** * Get the current log level */ getLevel(): LogLevel { return this.level } /** * Set a new log handler at runtime * * Use this to swap logging destinations without creating a new logger. * * @example * ```typescript * const logger = createLogger({ handler: consoleHandler }) * * // Later, switch to JSON output * logger.setHandler(new JsonHandler()) * ``` */ setHandler(handler: LogHandler): void { this.handler = handler } /** * Enable event capture */ enableCapture(maxEvents: number = 100): void { this.capturedEvents = new CircularBuffer(maxEvents) } /** * Disable event capture * Note: Captured logs are retained but no new logs will be captured */ disableCapture(): void { // Keep the captured events frozen but stop capturing new ones // by replacing with a frozen snapshot if (this.capturedEvents) { const snapshot = this.capturedEvents.toArray() this.capturedEvents = null // Store the frozen snapshot for later retrieval this.frozenCapture = snapshot } } /** * Get captured log entries */ getCapturedLogs(): LogEntry[] { // Return from active buffer or frozen snapshot (after disableCapture) return this.capturedEvents?.toArray() ?? this.frozenCapture ?? [] } /** * Clear captured logs */ clearCapturedLogs(): void { this.capturedEvents?.clear() } /** * Core log method - internal * Optimized to avoid unnecessary allocations when context is empty */ private logInternal( level: LogLevel, message: string, context?: LogContext ): void { if (level < this.level) { return } // Extract error from context if present const error = context?.error instanceof Error ? context.error : undefined // Optimize context merging - avoid object spread if not needed const hasBaseContext = Object.keys(this.baseContext).length > 0 const hasCallContext = context !== undefined && Object.keys(context).length > 0 let mergedContext: LogContext | undefined if (hasBaseContext && hasCallContext) { mergedContext = { ...this.baseContext, ...context } } else if (hasBaseContext) { mergedContext = this.baseContext } else if (hasCallContext) { mergedContext = context } // Build the message with prefix const formattedMessage = this.prefix ? `${this.prefix} ${message}` : message const entry: LogEntry = { level, levelName: LEVEL_NAMES[level], message: formattedMessage, timestamp: this.timestamps ? new Date().toISOString() : '', } if (mergedContext !== undefined) { entry.context = mergedContext } if (error !== undefined) { entry.error = error } // Capture if enabled this.capturedEvents?.push(entry) // Output via handler this.handler(entry) } /** * Log a raw entry (for TenantRouterLogger compatibility) */ log(entry: Partial): void { const level = entry.level ?? LogLevel.INFO if (level < this.level) { return } const fullEntry: LogEntry = { level, levelName: entry.levelName ?? LEVEL_NAMES[level], message: entry.message ?? '', timestamp: entry.timestamp ?? (this.timestamps ? new Date().toISOString() : ''), } if (entry.context !== undefined) { fullEntry.context = entry.context } if (entry.error !== undefined) { fullEntry.error = entry.error } // Capture if enabled this.capturedEvents?.push(fullEntry) // Output via handler this.handler(fullEntry) } /** * Log debug message */ debug(message: string, context?: LogContext): void { this.logInternal(LogLevel.DEBUG, message, context) } /** * Log info message */ info(message: string, context?: LogContext): void { this.logInternal(LogLevel.INFO, message, context) } /** * Log warning message */ warn(message: string, context?: LogContext): void { this.logInternal(LogLevel.WARN, message, context) } /** * Log error message */ error(message: string, context?: LogContext): void { this.logInternal(LogLevel.ERROR, message, context) } /** * Create a child logger with scoped context * This is the unified approach that replaces: * - CDCLogger.withSubscription() * - TenantRouterLogger context */ child(context: LogContext): ILogger { // Create a new logger that shares config but has merged context const childLogger = new Logger( { level: this.level, handler: this.handler, timestamps: this.timestamps, prefix: this.prefix, captureEvents: this.capturedEvents !== null, maxCapturedEvents: 100, // Child loggers share parent's capture }, { ...this.baseContext, ...context } ) // Share the same capture buffer with parent if (this.capturedEvents) { childLogger.capturedEvents = this.capturedEvents } return childLogger } } /** * Create a new logger instance * * This is the recommended way to create loggers. * * @param config - Logger configuration options * @returns ILogger instance * * @example * ```typescript * // Basic logger (INFO level, console output) * const logger = createLogger() * * // Debug logger with prefix * const logger = createLogger({ * level: LogLevel.DEBUG, * prefix: '[MyService]', * }) * * // JSON output logger * const logger = createLogger({ * handler: new JsonHandler(), * }) * * // Logger with event capture * const logger = createLogger({ * captureEvents: true, * maxCapturedEvents: 50, * }) * ``` */ export function createLogger(config?: LoggerConfig): ILogger { return new Logger(config) } // ============================================================================= // Type Aliases for Backward Compatibility // ============================================================================= /** * Simple logger interface for backward compatibility with tiered-orchestrator * The unified logger implements this interface automatically. */ export type SimpleLogger = Pick // ============================================================================= // Pluggable Handler Interface - Advanced Handlers // ============================================================================= /** * MultiHandler - sends logs to multiple destinations simultaneously * * Use this when you need to log to multiple destinations (e.g., console + analytics) * * @example * ```typescript * const multiHandler = new MultiHandler([ * new ConsoleHandler({ colors: true }), * new JsonHandler({ output: sendToRemote }), * ]) * * const logger = createLogger({ handler: multiHandler }) * logger.info('Logged to both destinations') * ``` */ export const MultiHandler = function (handlers: LogHandler[]): MultiLogHandler { const _handlers = [...handlers] const handler = ((entry: LogEntry): void => { for (const h of _handlers) { h(entry) } }) as MultiLogHandler // Attach methods to the handler function handler.addHandler = (h: LogHandler): void => { _handlers.push(h) } handler.removeHandler = (h: LogHandler): void => { const index = _handlers.indexOf(h) if (index !== -1) { _handlers.splice(index, 1) } } return handler } as unknown as new (handlers: LogHandler[]) => MultiLogHandler /** * FilterHandler - filters logs based on a custom predicate * * Use this to conditionally pass logs to another handler based on * log level, context fields, or any custom logic. * * @example * ```typescript * // Only log errors to analytics * const errorOnlyHandler = new FilterHandler( * analyticsHandler, * (entry) => entry.level >= LogLevel.ERROR * ) * * // Only log entries from specific tenant * const tenantHandler = new FilterHandler( * handler, * (entry) => entry.context?.tenantId === 'acme' * ) * ``` */ export const FilterHandler = function ( innerHandler: LogHandler, predicate: (entry: LogEntry) => boolean ): LogHandler { return (entry: LogEntry): void => { if (predicate(entry)) { innerHandler(entry) } } } as unknown as new ( handler: LogHandler, predicate: (entry: LogEntry) => boolean ) => LogHandler /** * Options for BatchHandler configuration */ export interface BatchHandlerOptions { /** Number of entries to batch before flushing */ batchSize: number /** Optional flush interval in milliseconds */ flushIntervalMs?: number /** Callback when batch is ready to be sent */ onBatch: (entries: LogEntry[]) => void } /** * BatchHandler - batches logs for performance * * Useful for reducing network requests when sending logs to a remote service. * Logs are accumulated and sent in batches when the batch size is reached * or when the flush interval expires. * * @example * ```typescript * const batchHandler = new BatchHandler({ * batchSize: 100, * flushIntervalMs: 5000, * onBatch: async (entries) => { * await fetch('/logs', { * method: 'POST', * body: JSON.stringify(entries), * }) * }, * }) * * const logger = createLogger({ handler: batchHandler }) * * // Don't forget to flush before worker terminates * batchHandler.flush() * ``` */ export const BatchHandler = function (options: BatchHandlerOptions): LogHandler { const { batchSize, flushIntervalMs, onBatch } = options let batch: LogEntry[] = [] let flushTimeout: ReturnType | null = null const flush = (): void => { if (flushTimeout !== null) { clearTimeout(flushTimeout) flushTimeout = null } if (batch.length > 0) { const toSend = batch batch = [] onBatch(toSend) } } const scheduleFlush = (): void => { if (flushIntervalMs && flushTimeout === null) { flushTimeout = setTimeout(flush, flushIntervalMs) } } const handler = ((entry: LogEntry): void => { batch.push(entry) if (batch.length >= batchSize) { flush() } else { scheduleFlush() } }) as BatchLogHandler // Attach flush method to handler handler.flush = flush return handler } as unknown as new (options: BatchHandlerOptions) => BatchLogHandler /** * Cloudflare Analytics Engine binding interface */ export interface AnalyticsEngineDataset { writeDataPoint(data: { blobs?: string[] doubles?: number[] indexes?: string[] }): void } /** * Options for CloudflareAnalyticsHandler */ export interface CloudflareAnalyticsHandlerOptions { /** The Analytics Engine binding from the worker */ analyticsEngine: AnalyticsEngineDataset /** Optional function to extract custom indexes from log entries */ extractIndexes?: (entry: LogEntry) => string[] /** Optional function to extract custom doubles (metrics) from log entries */ extractDoubles?: (entry: LogEntry) => number[] } /** * CloudflareAnalyticsHandler - sends logs to Cloudflare Analytics Engine * * This handler formats log entries for Cloudflare Workers Analytics Engine, * which provides SQL-queryable logging with up to 90 days retention. * * @example * ```typescript * // In your worker * export default { * async fetch(request, env) { * const handler = new CloudflareAnalyticsHandler({ * analyticsEngine: env.LOGS, * extractIndexes: (entry) => [ * entry.context?.tenantId as string || '', * ], * }) * * const logger = createLogger({ handler }) * logger.info('Request received', { tenantId: 'acme' }) * * return new Response('OK') * } * } * ``` * * Analytics Engine data format: * - blobs[0]: Log level name (e.g., "INFO") * - blobs[1]: Log message * - blobs[2]: JSON-encoded context (if present) * - blobs[3]: Timestamp * - indexes: Custom indexes from extractIndexes (for filtering/grouping) * - doubles: Custom metrics from extractDoubles */ export const CloudflareAnalyticsHandler = function ( options: CloudflareAnalyticsHandlerOptions ): LogHandler { const { analyticsEngine, extractIndexes, extractDoubles } = options return (entry: LogEntry): void => { const blobs: string[] = [ entry.levelName, entry.message, entry.context ? JSON.stringify(entry.context) : '', entry.timestamp, ] const indexes = extractIndexes ? extractIndexes(entry) : [] const doubles = extractDoubles ? extractDoubles(entry) : [entry.level] analyticsEngine.writeDataPoint({ blobs, indexes, doubles, }) } } as unknown as new (options: CloudflareAnalyticsHandlerOptions) => LogHandler