log-collector.ts148 lines · main
| 1 | import { AsyncLocalStorage } from 'node:async_hooks'; |
| 2 | |
| 3 | import { sanitizeErrorMessage } from './error-sanitizer.js'; |
| 4 | import type { LogLine } from './isolate-runtime/types.js'; |
| 5 | |
| 6 | /** |
| 7 | * Per-invocation log collector. Two sources feed it: |
| 8 | * |
| 9 | * 1. `ctx.log.{info,warn,error,debug}` — the structured API users should |
| 10 | * reach for first. Takes a message + optional fields object. |
| 11 | * 2. `console.{log,info,warn,error,debug}` — the fallback for anything |
| 12 | * ported from elsewhere. We patch the global `console` ONCE at module |
| 13 | * load, and route through an AsyncLocalStorage-bound collector so two |
| 14 | * concurrent invocations on the inline executor don't cross-stream. |
| 15 | * |
| 16 | * Before publish, fields whose key matches SECRET_KEY_RE are replaced with |
| 17 | * `[redacted]` so users who log their own env by accident don't leak it |
| 18 | * to the tail stream. |
| 19 | */ |
| 20 | |
| 21 | export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; |
| 22 | |
| 23 | export interface LogEntry { |
| 24 | level: LogLevel; |
| 25 | message: string; |
| 26 | fieldsJson?: string; |
| 27 | ts: string; |
| 28 | } |
| 29 | |
| 30 | export interface LogCollector { |
| 31 | debug(msg: string, fields?: Record<string, unknown>): void; |
| 32 | info(msg: string, fields?: Record<string, unknown>): void; |
| 33 | warn(msg: string, fields?: Record<string, unknown>): void; |
| 34 | error(msg: string, fields?: Record<string, unknown>): void; |
| 35 | drain(): LogEntry[]; |
| 36 | } |
| 37 | |
| 38 | const SECRET_KEY_RE = /^(password|secret|token|api[_-]?key|authorization)$/i; |
| 39 | const MAX_ENTRIES_PER_INVOCATION = 500; |
| 40 | |
| 41 | const collectorAls = new AsyncLocalStorage<LogCollector>(); |
| 42 | |
| 43 | export function createLogCollector(): LogCollector { |
| 44 | const entries: LogEntry[] = []; |
| 45 | function push(level: LogLevel, message: string, fields?: Record<string, unknown>): void { |
| 46 | if (entries.length >= MAX_ENTRIES_PER_INVOCATION) return; |
| 47 | const entry: LogEntry = { |
| 48 | level, |
| 49 | message: String(message), |
| 50 | ts: new Date().toISOString(), |
| 51 | }; |
| 52 | if (fields && Object.keys(fields).length > 0) { |
| 53 | entry.fieldsJson = JSON.stringify(redact(fields)); |
| 54 | } |
| 55 | entries.push(entry); |
| 56 | } |
| 57 | return { |
| 58 | debug: (m, f) => push('debug', m, f), |
| 59 | info: (m, f) => push('info', m, f), |
| 60 | warn: (m, f) => push('warn', m, f), |
| 61 | error: (m, f) => push('error', m, f), |
| 62 | drain: () => entries.splice(0, entries.length), |
| 63 | }; |
| 64 | } |
| 65 | |
| 66 | export async function runWithCollector<T>( |
| 67 | collector: LogCollector, |
| 68 | fn: () => Promise<T>, |
| 69 | ): Promise<T> { |
| 70 | return collectorAls.run(collector, fn); |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * One-time install: once called (from the module that sets up the runtime), |
| 75 | * `console.*` is redirected whenever there's an active collector in the |
| 76 | * async context. Outside an invocation the original `console` is used, so |
| 77 | * infra logs still reach stdout/stderr as normal. |
| 78 | */ |
| 79 | let installed = false; |
| 80 | export function installConsolePatch(): void { |
| 81 | if (installed) return; |
| 82 | installed = true; |
| 83 | const orig = { |
| 84 | log: globalThis.console.log.bind(globalThis.console), |
| 85 | info: globalThis.console.info.bind(globalThis.console), |
| 86 | warn: globalThis.console.warn.bind(globalThis.console), |
| 87 | error: globalThis.console.error.bind(globalThis.console), |
| 88 | debug: globalThis.console.debug.bind(globalThis.console), |
| 89 | }; |
| 90 | const route = (level: LogLevel, origFn: (...a: unknown[]) => void, args: unknown[]): void => { |
| 91 | const c = collectorAls.getStore(); |
| 92 | if (!c) { |
| 93 | origFn(...args); |
| 94 | return; |
| 95 | } |
| 96 | c[level](formatArgs(args)); |
| 97 | }; |
| 98 | globalThis.console.log = (...args: unknown[]) => route('info', orig.log, args); |
| 99 | globalThis.console.info = (...args: unknown[]) => route('info', orig.info, args); |
| 100 | globalThis.console.warn = (...args: unknown[]) => route('warn', orig.warn, args); |
| 101 | globalThis.console.error = (...args: unknown[]) => route('error', orig.error, args); |
| 102 | globalThis.console.debug = (...args: unknown[]) => route('debug', orig.debug, args); |
| 103 | } |
| 104 | |
| 105 | function formatArgs(args: readonly unknown[]): string { |
| 106 | return args |
| 107 | .map((a) => { |
| 108 | if (typeof a === 'string') return a; |
| 109 | if (a instanceof Error) return `${a.name}: ${a.message}`; |
| 110 | try { |
| 111 | return JSON.stringify(a); |
| 112 | } catch { |
| 113 | return String(a); |
| 114 | } |
| 115 | }) |
| 116 | .join(' '); |
| 117 | } |
| 118 | |
| 119 | function redact(fields: Record<string, unknown>): Record<string, unknown> { |
| 120 | const out: Record<string, unknown> = {}; |
| 121 | for (const [k, v] of Object.entries(fields)) { |
| 122 | out[k] = SECRET_KEY_RE.test(k) ? '[redacted]' : v; |
| 123 | } |
| 124 | return out; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Ingest a stderr line emitted by a Deno isolate. Sanitizes the message |
| 129 | * (env values, tmp paths, IPs) and forwards it to a sink. Phase 1: the |
| 130 | * default sink writes to real stderr as a JSON envelope so ops can |
| 131 | * still triage panics; Task 16+ swaps in a Redis-stream publisher and |
| 132 | * wires this into the per-project log path alongside `publishInvocation`. |
| 133 | * |
| 134 | * The pool's `onLog` callback is the natural call site — runtime |
| 135 | * bootstrap (Task 14) will pass in a closure that calls this with the |
| 136 | * project's env values pre-bound. |
| 137 | */ |
| 138 | export function ingestIsolateLogLine( |
| 139 | line: LogLine, |
| 140 | projectId: string, |
| 141 | envValues: readonly string[] = [], |
| 142 | sink: (payload: string) => void = (s) => { |
| 143 | console.error(s); |
| 144 | }, |
| 145 | ): void { |
| 146 | const sanitized = sanitizeErrorMessage(line.msg, envValues); |
| 147 | sink(JSON.stringify({ ...line, projectId, msg: sanitized })); |
| 148 | } |