@@ -16,10 +16,53 @@ export type LogLevel = "log" | "error" | "warn" | "info" | "debug" | "verbose";
1616
1717const logLevels : Array < LogLevel > = [ "log" , "error" , "warn" , "info" , "debug" , "verbose" ] ;
1818
19+ // Applied to every Logger instance, on top of whatever a caller passes in as `filteredKeys`.
20+ // Keeps the previous "opt-in, per-instance" list from being the only thing standing between a
21+ // logged object and a credential or piece of customer content that happens to share its name.
22+ const DEFAULT_FILTERED_KEYS = [
23+ "authorization" ,
24+ "token" ,
25+ "apikey" ,
26+ "secretkey" ,
27+ "accesstoken" ,
28+ "refreshtoken" ,
29+ "password" ,
30+ "jwt" ,
31+ "payload" ,
32+ "output" ,
33+ "metadata" ,
34+ "seedmetadata" ,
35+ "input" ,
36+ "email" ,
37+ "headers" ,
38+ "completedwaitpoints" ,
39+ ] ;
40+
41+ // Belt-and-braces value-shape check: catches secrets anywhere in values that land under a field
42+ // name we didn't think to deny-list (a trigger.dev API key, bearer token, or OpenAI-style key).
43+ const SECRET_VALUE_PATTERN = / ( t r _ [ a - z A - Z 0 - 9 _ - ] { 4 , } | s k - [ a - z A - Z 0 - 9 _ - ] { 4 , } | B e a r e r \s + \S + ) / ;
44+
45+ // Per-field and per-structure caps so a single unbounded object (a run payload, a batch of
46+ // items, a DB row) can't blow up log line size or CPU. Truncation keeps the field present and
47+ // queryable rather than dropping it.
48+ const MAX_STRING_LENGTH = 8192 ;
49+ const MAX_ARRAY_LENGTH = 100 ;
50+ const MAX_DEPTH = 10 ;
51+
52+ function buildFilteredKeySet ( filteredKeys : string [ ] ) : Set < string > {
53+ const set = new Set ( DEFAULT_FILTERED_KEYS ) ;
54+
55+ for ( const key of filteredKeys ) {
56+ set . add ( key . toLowerCase ( ) ) ;
57+ }
58+
59+ return set ;
60+ }
61+
1962export class Logger {
2063 #name: string ;
2164 readonly #level: number ;
22- #filteredKeys: string [ ] = [ ] ;
65+ #filteredKeys: Set < string > = new Set ( DEFAULT_FILTERED_KEYS ) ;
2366 #jsonReplacer?: ( key : string , value : unknown ) => unknown ;
2467 #additionalFields: ( ) => Record < string , unknown > ;
2568
@@ -39,7 +82,7 @@ export class Logger {
3982 ) {
4083 this . #name = name ;
4184 this . #level = logLevels . indexOf ( ( env . TRIGGER_LOG_LEVEL ?? level ) as LogLevel ) ;
42- this . #filteredKeys = filteredKeys ;
85+ this . #filteredKeys = buildFilteredKeySet ( filteredKeys ) ;
4386 this . #jsonReplacer = createReplacer ( jsonReplacer ) ;
4487 this . #additionalFields = additionalFields ?? ( ( ) => ( { } ) ) ;
4588 }
@@ -48,7 +91,7 @@ export class Logger {
4891 return new Logger (
4992 this . #name,
5093 logLevels [ this . #level] ,
51- this . #filteredKeys,
94+ Array . from ( this . #filteredKeys) ,
5295 this . #jsonReplacer,
5396 ( ) => ( { ...this . #additionalFields( ) , ...fields } )
5497 ) ;
@@ -115,8 +158,8 @@ export class Logger {
115158 // Get the current context from trace if it exists
116159 const currentSpan = trace . getSpan ( context . active ( ) ) ;
117160
118- const structuredError = extractStructuredErrorFromArgs ( ...args ) ;
119- const structuredMessage = extractStructuredMessageFromArgs ( ...args ) ;
161+ const structuredError = extractStructuredErrorFromArgs ( this . #filteredKeys , ...args ) ;
162+ const structuredMessage = extractStructuredMessageFromArgs ( this . #filteredKeys , ...args ) ;
120163
121164 const structuredLog = {
122165 ...structureArgs ( safeJsonClone ( args ) as Record < string , unknown > [ ] , this . #filteredKeys) ,
@@ -153,38 +196,52 @@ export class Logger {
153196// Detect if args is an error object
154197// Or if args contains an error object at the "error" key
155198// In both cases, return the error object as a structured error
156- function extractStructuredErrorFromArgs ( ...args : Array < Record < string , unknown > | undefined > ) {
157- const error = args . find ( ( arg ) => arg instanceof Error ) as Error | undefined ;
199+ // Run every field through the same filter/truncation used for the rest of the log line, so an
200+ // error's message/stack/metadata (which can embed request or row data verbatim) gets the same
201+ // treatment as everything else, instead of bypassing it.
202+ function extractStructuredErrorFromArgs (
203+ filteredKeys : Set < string > ,
204+ ...args : Array < Record < string , unknown > | undefined >
205+ ) {
206+ const error = args . find ( ( arg ) => arg instanceof Error ) as
207+ | ( Error & { metadata ?: unknown } )
208+ | undefined ;
158209
159210 if ( error ) {
160211 return {
161- message : error . message ,
162- stack : error . stack ,
212+ message : filterKeys ( error . message , filteredKeys ) ,
213+ stack : filterKeys ( error . stack , filteredKeys ) ,
163214 name : error . name ,
164- metadata : "metadata" in error ? error . metadata : undefined ,
215+ metadata : "metadata" in error ? filterKeys ( error . metadata , filteredKeys ) : undefined ,
165216 } ;
166217 }
167218
168219 const structuredError = args . find ( ( arg ) => arg ?. error ) ;
169220
170221 if ( structuredError && structuredError . error instanceof Error ) {
222+ const nestedError = structuredError . error as Error & { metadata ?: unknown } ;
223+
171224 return {
172- message : structuredError . error . message ,
173- stack : structuredError . error . stack ,
174- name : structuredError . error . name ,
175- metadata : "metadata" in structuredError . error ? structuredError . error . metadata : undefined ,
225+ message : filterKeys ( nestedError . message , filteredKeys ) ,
226+ stack : filterKeys ( nestedError . stack , filteredKeys ) ,
227+ name : nestedError . name ,
228+ metadata :
229+ "metadata" in nestedError ? filterKeys ( nestedError . metadata , filteredKeys ) : undefined ,
176230 } ;
177231 }
178232
179233 return ;
180234}
181235
182- function extractStructuredMessageFromArgs ( ...args : Array < Record < string , unknown > | undefined > ) {
236+ function extractStructuredMessageFromArgs (
237+ filteredKeys : Set < string > ,
238+ ...args : Array < Record < string , unknown > | undefined >
239+ ) {
183240 // Check to see if there is a `message` key in the args, and if so, return it
184241 const structuredMessage = args . find ( ( arg ) => arg ?. message ) ;
185242
186243 if ( structuredMessage ) {
187- return structuredMessage . message ;
244+ return filterKeys ( structuredMessage . message , filteredKeys ) ;
188245 }
189246
190247 return ;
@@ -221,37 +278,65 @@ function safeJsonClone(obj: unknown) {
221278 }
222279}
223280
224- // If args is has a single item that is an object, return that object
225- function structureArgs ( args : Array < Record < string , unknown > > , filteredKeys : string [ ] = [ ] ) {
226- if ( ! args ) {
281+ // `args` has already been through safeJsonClone, so this only has to filter/truncate it, not
282+ // clone it again. If there's exactly one arg, return it directly (unwrapped) so it can be spread
283+ // onto the structured log; otherwise filter every arg and return the array. Filtering runs
284+ // regardless of arg count, so a multi-arg call gets the same redaction as the common single-arg
285+ // case.
286+ function structureArgs (
287+ args : Array < Record < string , unknown > > | undefined ,
288+ filteredKeys : Set < string > = new Set ( )
289+ ) {
290+ if ( ! args || args . length === 0 ) {
227291 return ;
228292 }
229293
230- if ( args . length === 0 ) {
231- return ;
232- }
294+ const filteredArgs = args . map ( ( arg ) => filterKeys ( arg , filteredKeys ) ) ;
233295
234- if ( args . length === 1 && typeof args [ 0 ] === "object" ) {
235- return filterKeys ( JSON . parse ( JSON . stringify ( args [ 0 ] , bigIntReplacer ) ) , filteredKeys ) ;
296+ if ( filteredArgs . length === 1 ) {
297+ return filteredArgs [ 0 ] ;
236298 }
237299
238- return args ;
300+ return filteredArgs ;
239301}
240302
241- // Recursively filter out keys from an object, including nested objects, and arrays
242- function filterKeys ( obj : unknown , keys : string [ ] ) : any {
303+ // Recursively filter out keys from an object, including nested objects and arrays. Also caps
304+ // string length, array length and recursion depth, and redacts string values that look like a
305+ // secret regardless of which key they were found under.
306+ function filterKeys ( obj : unknown , keys : Set < string > , depth = 0 ) : any {
307+ if ( typeof obj === "string" ) {
308+ if ( SECRET_VALUE_PATTERN . test ( obj ) ) {
309+ return `[filtered ${ prettyPrintBytes ( obj ) } ]` ;
310+ }
311+
312+ return truncateString ( obj ) ;
313+ }
314+
243315 if ( typeof obj !== "object" || obj === null ) {
244316 return obj ;
245317 }
246318
319+ if ( depth >= MAX_DEPTH ) {
320+ return "[max depth exceeded]" ;
321+ }
322+
247323 if ( Array . isArray ( obj ) ) {
248- return obj . map ( ( item ) => filterKeys ( item , keys ) ) ;
324+ const isTruncated = obj . length > MAX_ARRAY_LENGTH ;
325+ const items = ( isTruncated ? obj . slice ( 0 , MAX_ARRAY_LENGTH ) : obj ) . map ( ( item ) =>
326+ filterKeys ( item , keys , depth + 1 )
327+ ) ;
328+
329+ if ( isTruncated ) {
330+ items . push ( `[truncated ${ obj . length - MAX_ARRAY_LENGTH } more items]` ) ;
331+ }
332+
333+ return items ;
249334 }
250335
251336 const filteredObj : any = { } ;
252337
253338 for ( const [ key , value ] of Object . entries ( obj ) ) {
254- if ( keys . includes ( key ) ) {
339+ if ( keys . has ( key . toLowerCase ( ) ) ) {
255340 if ( value ) {
256341 filteredObj [ key ] = `[filtered ${ prettyPrintBytes ( value ) } ]` ;
257342 } else {
@@ -260,12 +345,29 @@ function filterKeys(obj: unknown, keys: string[]): any {
260345 continue ;
261346 }
262347
263- filteredObj [ key ] = filterKeys ( value , keys ) ;
348+ filteredObj [ key ] = filterKeys ( value , keys , depth + 1 ) ;
264349 }
265350
266351 return filteredObj ;
267352}
268353
354+ function truncateString ( value : string ) : string {
355+ if ( value . length <= MAX_STRING_LENGTH ) {
356+ return value ;
357+ }
358+
359+ return `${ value . slice ( 0 , MAX_STRING_LENGTH ) } ...[truncated ${
360+ value . length - MAX_STRING_LENGTH
361+ } chars]`;
362+ }
363+
364+ // Runs a value through the same default-deny-list + truncation pipeline every Logger applies to
365+ // its own log lines. For destinations that receive log arguments through a side channel (e.g. an
366+ // error reporting `onError` hook) rather than through `Logger#structuredLog` itself.
367+ export function redact ( value : unknown , filteredKeys : string [ ] = [ ] ) : unknown {
368+ return filterKeys ( value , buildFilteredKeySet ( filteredKeys ) ) ;
369+ }
370+
269371function prettyPrintBytes ( value : unknown ) : string {
270372 if ( env . NODE_ENV === "production" ) {
271373 return "skipped size" ;
0 commit comments