import type { LogContext, LogEntry } from './types'; export const MSG_MAX_CHARS = 200; export const CTX_VALUE_MAX_CHARS = 2048; export const STACK_MAX_BYTES = 2048; export const ENTRY_MAX_BYTES = 4096; export const REDACT_MAX_DEPTH = 10; const REDACTED_KEYS: ReadonlySet = new Set([ 'authorization', 'apikey', 'token', 'refreshtoken', 'accesstoken', 'idtoken', 'password', 'secret', 'clientsecret', 'apisecret', 'cookie', 'setcookie', 'bearer', ]); const SAFE_KEY_RE = /^[\w.-]+$/; const ELLIPSIS = '…'; const TRUNCATED_TAIL = ` …truncated`; const REDACTED = '[REDACTED]'; const RAW_SECRET_PATTERNS: readonly RegExp[] = [ /\b(authorization\s*[:=]\s*bearer\s+)[^\s"'`]+/gi, /\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret)\s*[:=]\s*)[^\s"'`]+/gi, /\b(cookie\s*[:=]\s*)[^\r\n]+/gi, ]; const LEVEL_LABEL: Record, string> = { error: 'ERROR', warn: 'WARN ', info: 'INFO ', debug: 'DEBUG', }; function normalizeKey(key: string): string { return key.toLowerCase().replaceAll(/[_\-.]/g, ''); } export function redactCtx(ctx: LogContext): LogContext { const seen = new WeakSet(); const walk = (value: unknown, depth: number): unknown => { if (depth > REDACT_MAX_DEPTH) return '[REDACTED:depth]'; if (value === null || typeof value !== 'object') return value; if (seen.has(value)) return '[REDACTED:cycle]'; seen.add(value); if (Array.isArray(value)) { return value.map((item) => walk(item, depth + 1)); } const out: Record = {}; for (const [key, raw] of Object.entries(value as Record)) { out[key] = REDACTED_KEYS.has(normalizeKey(key)) ? REDACTED : walk(raw, depth + 1); } return out; }; return walk(ctx, 0) as LogContext; } export interface FormattedEntry { readonly text: string; readonly dropped: boolean; } function truncate(value: string, max: number): string { return value.length <= max ? value : value.slice(0, max - 1) + ELLIPSIS; } function serializeValue(raw: unknown): string { if (typeof raw === 'string') return redactString(raw); if (raw === undefined) return 'undefined'; if (raw === null) return 'null'; if ( typeof raw === 'number' || typeof raw === 'boolean' || typeof raw === 'bigint' || typeof raw === 'symbol' ) { return String(raw); } try { const json = JSON.stringify(raw); if (json !== undefined) return json; } catch { } if (typeof raw === 'function') return raw.name === '' ? '[Function]' : `[Function: ${raw.name}]`; return Object.prototype.toString.call(raw); } function redactString(value: string): string { let out = value; for (const pattern of RAW_SECRET_PATTERNS) { out = out.replace(pattern, `$1${REDACTED}`); } return out; } function quote(value: string): string { return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`; } function formatPair(key: string, raw: unknown): string { const limited = truncate(serializeValue(raw), CTX_VALUE_MAX_CHARS); const renderedKey = SAFE_KEY_RE.test(key) ? key : quote(key); const renderedVal = /[\s="\\]/.test(limited) || limited.length === 0 ? quote(limited) : limited; return `${renderedKey}=${renderedVal}`; } function clipBytes(text: string, maxBytes: number): string { if (Buffer.byteLength(text, 'utf-8') <= maxBytes) return text; let lo = 0; let hi = text.length; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if ( Buffer.byteLength(text.slice(0, mid), 'utf-8') <= maxBytes - Buffer.byteLength(TRUNCATED_TAIL, 'utf-8') ) { lo = mid; } else { hi = mid - 1; } } return text.slice(0, lo) + TRUNCATED_TAIL; } function clipStack(stack: string): string { if (Buffer.byteLength(stack, 'utf-8') <= STACK_MAX_BYTES) return stack; return clipBytes(stack, STACK_MAX_BYTES); } function indentStack(stack: string): string { return stack .split('\n') .map((line, i) => (i === 0 ? ` ${line}` : ` ${line.trimStart()}`)) .join('\n'); } export function formatEntry(entry: LogEntry): FormattedEntry { const ctx = entry.ctx ? redactCtx(entry.ctx) : undefined; const msg = truncate(entry.msg, MSG_MAX_CHARS); const pairs: string[] = []; if (ctx) { for (const [k, v] of Object.entries(ctx)) { if (v !== undefined) pairs.push(formatPair(k, v)); } } const time = new Date(entry.t).toISOString(); const label = LEVEL_LABEL[entry.level]; const rendered = pairs.length === 0 ? `${time} ${label} ${msg}` : `${time} ${label} ${msg} ${pairs.join(' ')}`; let head = Buffer.byteLength(rendered, 'utf-8') > ENTRY_MAX_BYTES ? clipBytes(rendered, ENTRY_MAX_BYTES) : rendered; if (entry.error?.stack) { head = `${head}\n${indentStack(clipStack(redactString(entry.error.stack)))}`; } else if (entry.error?.message) { head = `${head}\n Error: ${redactString(entry.error.message)}`; } return { text: head, dropped: false }; } export function extractError(value: Error): { message: string; stack?: string } { return typeof value.stack === 'string' ? { message: value.message, stack: value.stack } : { message: value.message }; }