logstream.js (5228B)
1 // src/worker/logstream.js - batching log shipper with masking 2 // 3 // Task output arrives as many small writes; shipping each one would be a 4 // request per line. Output is accumulated and flushed on a timer, or as soon 5 // as it grows past a threshold. 6 // 7 // The offset the conductor has accepted is tracked locally, so a retry after 8 // a dropped connection resends from the right place rather than duplicating 9 // or losing output. A 409 carries the conductor's view of the offset and 10 // wins, since it is the side holding the file. 11 // 12 // Masking happens here, before anything leaves the host. Two details make it 13 // actually work: 14 // 15 // Masking is applied when a batch is assembled, not as each write 16 // arrives, and the last few characters are held back until more output 17 // appears. A secret written across two chunks is therefore still matched. 18 // Masking each write on its own would defeat the hold back entirely. 19 // 20 // While masking is active the stream is decoded as UTF-8 with a 21 // StringDecoder, so a multi byte character split across writes is not 22 // mangled. With no secrets configured the bytes are passed through 23 // untouched and the stream stays binary safe. 24 25 import { StringDecoder } from 'node:string_decoder'; 26 27 const FLUSH_BYTES = 16 * 1024; 28 const FLUSH_MS = 1000; 29 const REDACTION = '[masked]'; 30 31 // Below this length a value is too common to redact usefully, and would 32 // turn ordinary output into noise. 33 const MIN_SECRET_LENGTH = 4; 34 35 export function createLogStream(client, url, { masked = [], flushMs = FLUSH_MS, logger = console } = {}) { 36 // Longest first, so an overlapping shorter secret cannot partially 37 // reveal a longer one. 38 const secrets = [...new Set(masked.filter((s) => typeof s === 'string' && s.length >= MIN_SECRET_LENGTH))] 39 .sort((a, b) => b.length - a.length); 40 const masking = secrets.length > 0; 41 const holdBack = masking ? Math.max(...secrets.map((s) => s.length)) - 1 : 0; 42 43 const decoder = masking ? new StringDecoder('utf8') : null; 44 let text = ''; 45 let bytes = Buffer.alloc(0); 46 let outbox = Buffer.alloc(0); 47 48 let offset = 0; 49 let timer = null; 50 let flushing = null; 51 let ended = false; 52 let failed = null; 53 54 function mask(value) { 55 let out = value; 56 for (const secret of secrets) out = out.split(secret).join(REDACTION); 57 return out; 58 } 59 60 function buffered() { 61 return outbox.length + (masking ? text.length : bytes.length); 62 } 63 64 // Moves everything that is safe to send into the outbox, masking on the 65 // way. Anything within holdBack of the end waits for more input, unless 66 // this is the final flush. 67 function assemble(final) { 68 if (!masking) { 69 if (bytes.length === 0) return; 70 outbox = Buffer.concat([outbox, bytes]); 71 bytes = Buffer.alloc(0); 72 return; 73 } 74 75 if (final) text += decoder.end(); 76 const keep = final ? 0 : Math.min(holdBack, text.length); 77 const take = text.slice(0, text.length - keep); 78 text = text.slice(text.length - keep); 79 if (take.length === 0) return; 80 outbox = Buffer.concat([outbox, Buffer.from(mask(take), 'utf8')]); 81 } 82 83 function schedule() { 84 if (timer !== null || ended) return; 85 timer = setTimeout(() => { timer = null; void flush(); }, flushMs); 86 timer.unref?.(); 87 } 88 89 async function flush(final = false) { 90 if (timer !== null) { 91 clearTimeout(timer); 92 timer = null; 93 } 94 // Serialize, so two flushes cannot race on the offset. 95 while (flushing) await flushing; 96 97 assemble(final); 98 if (outbox.length === 0) return; 99 100 flushing = (async () => { 101 while (outbox.length > 0) { 102 const chunk = outbox; 103 outbox = Buffer.alloc(0); 104 try { 105 const result = await client.appendLog(url, chunk, offset); 106 if (result.conflict) { 107 // The conductor knows what it already has; drop that much and 108 // resend the remainder. 109 const skip = result.expected - offset; 110 offset = result.expected; 111 const remainder = skip > 0 ? chunk.subarray(Math.min(skip, chunk.length)) : chunk; 112 outbox = Buffer.concat([remainder, outbox]); 113 continue; 114 } 115 offset = result.size; 116 } catch (e) { 117 // Losing log output must not fail an otherwise good task. 118 failed = e; 119 logger.warn?.(`log shipping failed, dropping ${chunk.length} bytes: ${e.message}`); 120 } 121 } 122 })(); 123 124 try { 125 await flushing; 126 } finally { 127 flushing = null; 128 } 129 } 130 131 return { 132 write(chunk) { 133 if (ended) return; 134 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'); 135 if (masking) text += decoder.write(buffer); 136 else bytes = Buffer.concat([bytes, buffer]); 137 138 if (buffered() >= FLUSH_BYTES) void flush(); 139 else schedule(); 140 }, 141 142 // A line from the worker rather than the task, so the log can explain a 143 // timeout, a cancellation or a setup failure. 144 note(line) { 145 this.write(`${line}\n`); 146 }, 147 148 async close() { 149 if (ended) return; 150 await flush(true); 151 ended = true; 152 }, 153 154 get offset() { 155 return offset; 156 }, 157 158 get error() { 159 return failed; 160 }, 161 }; 162 }