@@ -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 that land under a field name we didn't
42+ // think to deny-list (a live trigger.dev API key, a bearer token, or an OpenAI-style secret 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,7 +158,7 @@ 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 ) ;
161+ const structuredError = extractStructuredErrorFromArgs ( this . #filteredKeys , ...args ) ;
119162 const structuredMessage = extractStructuredMessageFromArgs ( ...args ) ;
120163
121164 const structuredLog = {
@@ -153,26 +196,36 @@ 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 : "metadata" in nestedError ? filterKeys ( nestedError . metadata , filteredKeys ) : undefined ,
176229 } ;
177230 }
178231
@@ -221,37 +274,65 @@ function safeJsonClone(obj: unknown) {
221274 }
222275}
223276
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 ) {
277+ // `args` has already been through safeJsonClone, so this only has to filter/truncate it, not
278+ // clone it again. If there's exactly one arg, return it directly (unwrapped) so it can be spread
279+ // onto the structured log; otherwise filter every arg and return the array. Filtering runs
280+ // regardless of arg count, so a multi-arg call gets the same redaction as the common single-arg
281+ // case.
282+ function structureArgs (
283+ args : Array < Record < string , unknown > > | undefined ,
284+ filteredKeys : Set < string > = new Set ( )
285+ ) {
286+ if ( ! args || args . length === 0 ) {
227287 return ;
228288 }
229289
230- if ( args . length === 0 ) {
231- return ;
232- }
290+ const filteredArgs = args . map ( ( arg ) => filterKeys ( arg , filteredKeys ) ) ;
233291
234- if ( args . length === 1 && typeof args [ 0 ] === "object" ) {
235- return filterKeys ( JSON . parse ( JSON . stringify ( args [ 0 ] , bigIntReplacer ) ) , filteredKeys ) ;
292+ if ( filteredArgs . length === 1 ) {
293+ return filteredArgs [ 0 ] ;
236294 }
237295
238- return args ;
296+ return filteredArgs ;
239297}
240298
241- // Recursively filter out keys from an object, including nested objects, and arrays
242- function filterKeys ( obj : unknown , keys : string [ ] ) : any {
299+ // Recursively filter out keys from an object, including nested objects and arrays. Also caps
300+ // string length, array length and recursion depth, and redacts string values that look like a
301+ // secret regardless of which key they were found under.
302+ function filterKeys ( obj : unknown , keys : Set < string > , depth = 0 ) : any {
303+ if ( typeof obj === "string" ) {
304+ if ( SECRET_VALUE_PATTERN . test ( obj ) ) {
305+ return `[filtered ${ prettyPrintBytes ( obj ) } ]` ;
306+ }
307+
308+ return truncateString ( obj ) ;
309+ }
310+
243311 if ( typeof obj !== "object" || obj === null ) {
244312 return obj ;
245313 }
246314
315+ if ( depth >= MAX_DEPTH ) {
316+ return "[max depth exceeded]" ;
317+ }
318+
247319 if ( Array . isArray ( obj ) ) {
248- return obj . map ( ( item ) => filterKeys ( item , keys ) ) ;
320+ const isTruncated = obj . length > MAX_ARRAY_LENGTH ;
321+ const items = ( isTruncated ? obj . slice ( 0 , MAX_ARRAY_LENGTH ) : obj ) . map ( ( item ) =>
322+ filterKeys ( item , keys , depth + 1 )
323+ ) ;
324+
325+ if ( isTruncated ) {
326+ items . push ( `[truncated ${ obj . length - MAX_ARRAY_LENGTH } more items]` ) ;
327+ }
328+
329+ return items ;
249330 }
250331
251332 const filteredObj : any = { } ;
252333
253334 for ( const [ key , value ] of Object . entries ( obj ) ) {
254- if ( keys . includes ( key ) ) {
335+ if ( keys . has ( key . toLowerCase ( ) ) ) {
255336 if ( value ) {
256337 filteredObj [ key ] = `[filtered ${ prettyPrintBytes ( value ) } ]` ;
257338 } else {
@@ -260,12 +341,29 @@ function filterKeys(obj: unknown, keys: string[]): any {
260341 continue ;
261342 }
262343
263- filteredObj [ key ] = filterKeys ( value , keys ) ;
344+ filteredObj [ key ] = filterKeys ( value , keys , depth + 1 ) ;
264345 }
265346
266347 return filteredObj ;
267348}
268349
350+ function truncateString ( value : string ) : string {
351+ if ( value . length <= MAX_STRING_LENGTH ) {
352+ return value ;
353+ }
354+
355+ return `${ value . slice ( 0 , MAX_STRING_LENGTH ) } ...[truncated ${
356+ value . length - MAX_STRING_LENGTH
357+ } chars]`;
358+ }
359+
360+ // Runs a value through the same default-deny-list + truncation pipeline every Logger applies to
361+ // its own log lines. For destinations that receive log arguments through a side channel (e.g. an
362+ // error reporting `onError` hook) rather than through `Logger#structuredLog` itself.
363+ export function redact ( value : unknown , filteredKeys : string [ ] = [ ] ) : unknown {
364+ return filterKeys ( value , buildFilteredKeySet ( filteredKeys ) ) ;
365+ }
366+
269367function prettyPrintBytes ( value : unknown ) : string {
270368 if ( env . NODE_ENV === "production" ) {
271369 return "skipped size" ;
0 commit comments