autotel 2.26.0 → 2.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/attribute-redacting-processor.cjs +14 -6
  2. package/dist/attribute-redacting-processor.d.cts +63 -1
  3. package/dist/attribute-redacting-processor.d.ts +63 -1
  4. package/dist/attribute-redacting-processor.js +1 -1
  5. package/dist/attributes.cjs +21 -21
  6. package/dist/attributes.js +2 -2
  7. package/dist/auto.cjs +8 -8
  8. package/dist/auto.js +6 -6
  9. package/dist/{chunk-RUD7KS4R.js → chunk-3SDILILG.js} +3 -3
  10. package/dist/{chunk-RUD7KS4R.js.map → chunk-3SDILILG.js.map} +1 -1
  11. package/dist/{chunk-B33XPEKY.js → chunk-55ER2KD5.js} +4 -4
  12. package/dist/chunk-55ER2KD5.js.map +1 -0
  13. package/dist/{chunk-UJJPTSEI.cjs → chunk-563EL6O6.cjs} +81 -14
  14. package/dist/chunk-563EL6O6.cjs.map +1 -0
  15. package/dist/{chunk-TS7IHIRW.cjs → chunk-6YGUN7IY.cjs} +5 -5
  16. package/dist/{chunk-TS7IHIRW.cjs.map → chunk-6YGUN7IY.cjs.map} +1 -1
  17. package/dist/{chunk-XDKK53OL.js → chunk-A4E5AQFK.js} +3 -3
  18. package/dist/{chunk-XDKK53OL.js.map → chunk-A4E5AQFK.js.map} +1 -1
  19. package/dist/{chunk-WAB4CHBU.js → chunk-BJ2XPN77.js} +3 -3
  20. package/dist/{chunk-WAB4CHBU.js.map → chunk-BJ2XPN77.js.map} +1 -1
  21. package/dist/{chunk-KZEC4CHV.cjs → chunk-CEAQK2QY.cjs} +5 -5
  22. package/dist/{chunk-KZEC4CHV.cjs.map → chunk-CEAQK2QY.cjs.map} +1 -1
  23. package/dist/chunk-CMNGGTQL.cjs +349 -0
  24. package/dist/chunk-CMNGGTQL.cjs.map +1 -0
  25. package/dist/{chunk-VYA6QDNA.js → chunk-DPSA4QLA.js} +4 -2
  26. package/dist/chunk-DPSA4QLA.js.map +1 -0
  27. package/dist/{chunk-M4US3P4K.js → chunk-ER43K7ES.js} +3 -3
  28. package/dist/{chunk-M4US3P4K.js.map → chunk-ER43K7ES.js.map} +1 -1
  29. package/dist/{chunk-AZ24DJAG.cjs → chunk-FU6R566Y.cjs} +4 -4
  30. package/dist/chunk-FU6R566Y.cjs.map +1 -0
  31. package/dist/{chunk-4PTCDOZY.js → chunk-HPUGKUMZ.js} +4 -4
  32. package/dist/{chunk-4PTCDOZY.js.map → chunk-HPUGKUMZ.js.map} +1 -1
  33. package/dist/{chunk-XRBP4RYL.cjs → chunk-JKIMEPI2.cjs} +4 -4
  34. package/dist/{chunk-XRBP4RYL.cjs.map → chunk-JKIMEPI2.cjs.map} +1 -1
  35. package/dist/{chunk-N344PVE5.cjs → chunk-OBWXM4NN.cjs} +9 -9
  36. package/dist/{chunk-N344PVE5.cjs.map → chunk-OBWXM4NN.cjs.map} +1 -1
  37. package/dist/{chunk-OFPZULMQ.cjs → chunk-OC6X2VIN.cjs} +8 -8
  38. package/dist/{chunk-OFPZULMQ.cjs.map → chunk-OC6X2VIN.cjs.map} +1 -1
  39. package/dist/{chunk-GTD3NXOS.js → chunk-QC5MNKVF.js} +4 -4
  40. package/dist/{chunk-GTD3NXOS.js.map → chunk-QC5MNKVF.js.map} +1 -1
  41. package/dist/chunk-TDNKIHKT.js +341 -0
  42. package/dist/chunk-TDNKIHKT.js.map +1 -0
  43. package/dist/{chunk-DGPUZ6TE.js → chunk-U54FTVFH.js} +3 -3
  44. package/dist/{chunk-DGPUZ6TE.js.map → chunk-U54FTVFH.js.map} +1 -1
  45. package/dist/{chunk-ZJ5GXCOT.cjs → chunk-UTZR7P7E.cjs} +36 -36
  46. package/dist/{chunk-ZJ5GXCOT.cjs.map → chunk-UTZR7P7E.cjs.map} +1 -1
  47. package/dist/{chunk-7FIGORWI.cjs → chunk-VH77IPJN.cjs} +4 -2
  48. package/dist/chunk-VH77IPJN.cjs.map +1 -0
  49. package/dist/{chunk-EXOXDI5A.js → chunk-W35FVJBC.js} +73 -8
  50. package/dist/chunk-W35FVJBC.js.map +1 -0
  51. package/dist/{chunk-II7GFVAF.cjs → chunk-WZOKY3PW.cjs} +13 -13
  52. package/dist/{chunk-II7GFVAF.cjs.map → chunk-WZOKY3PW.cjs.map} +1 -1
  53. package/dist/{chunk-CMADDTHY.cjs → chunk-YEVCD6DR.cjs} +7 -7
  54. package/dist/{chunk-CMADDTHY.cjs.map → chunk-YEVCD6DR.cjs.map} +1 -1
  55. package/dist/{chunk-RXFZKLRQ.js → chunk-YN7USLHW.js} +3 -3
  56. package/dist/{chunk-RXFZKLRQ.js.map → chunk-YN7USLHW.js.map} +1 -1
  57. package/dist/decorators.cjs +7 -7
  58. package/dist/decorators.js +7 -7
  59. package/dist/event.cjs +10 -10
  60. package/dist/event.js +7 -7
  61. package/dist/functional.cjs +14 -14
  62. package/dist/functional.js +7 -7
  63. package/dist/index.cjs +340 -97
  64. package/dist/index.cjs.map +1 -1
  65. package/dist/index.d.cts +205 -3
  66. package/dist/index.d.ts +205 -3
  67. package/dist/index.js +257 -33
  68. package/dist/index.js.map +1 -1
  69. package/dist/{init-QSj7X6zU.d.cts → init-CMuTaFAV.d.cts} +26 -1
  70. package/dist/{init-FiR_glVc.d.ts → init-D6JfWEjL.d.ts} +26 -1
  71. package/dist/instrumentation.cjs +14 -14
  72. package/dist/instrumentation.js +6 -6
  73. package/dist/logger.cjs +8 -8
  74. package/dist/logger.js +1 -1
  75. package/dist/messaging.cjs +11 -11
  76. package/dist/messaging.js +8 -8
  77. package/dist/metric.cjs +1 -1
  78. package/dist/metric.js +1 -1
  79. package/dist/sampling.cjs +15 -15
  80. package/dist/sampling.js +2 -2
  81. package/dist/semantic-helpers.cjs +12 -12
  82. package/dist/semantic-helpers.js +8 -8
  83. package/dist/tail-sampling-processor.cjs +4 -4
  84. package/dist/tail-sampling-processor.js +3 -3
  85. package/dist/testing.cjs +1 -1
  86. package/dist/testing.js +1 -1
  87. package/dist/webhook.cjs +9 -8
  88. package/dist/webhook.cjs.map +1 -1
  89. package/dist/webhook.js +8 -7
  90. package/dist/webhook.js.map +1 -1
  91. package/dist/workflow-distributed.cjs +9 -9
  92. package/dist/workflow-distributed.js +7 -7
  93. package/dist/workflow.cjs +12 -12
  94. package/dist/workflow.js +8 -8
  95. package/dist/yaml-config.cjs +6 -6
  96. package/dist/yaml-config.d.cts +1 -1
  97. package/dist/yaml-config.d.ts +1 -1
  98. package/dist/yaml-config.js +3 -3
  99. package/package.json +1 -1
  100. package/src/attribute-redacting-processor.test.ts +81 -16
  101. package/src/attribute-redacting-processor.ts +278 -24
  102. package/src/autotel-logger.ts +2 -2
  103. package/src/gen-ai-events.test.ts +135 -0
  104. package/src/gen-ai-events.ts +199 -0
  105. package/src/gen-ai-metrics.test.ts +96 -0
  106. package/src/gen-ai-metrics.ts +128 -0
  107. package/src/index.ts +28 -1
  108. package/src/init.ts +117 -2
  109. package/src/request-logger.test.ts +266 -1
  110. package/src/request-logger.ts +115 -16
  111. package/src/structured-error.ts +54 -1
  112. package/dist/chunk-7FIGORWI.cjs.map +0 -1
  113. package/dist/chunk-AZ24DJAG.cjs.map +0 -1
  114. package/dist/chunk-B33XPEKY.js.map +0 -1
  115. package/dist/chunk-ELW34S4C.cjs +0 -173
  116. package/dist/chunk-ELW34S4C.cjs.map +0 -1
  117. package/dist/chunk-EXOXDI5A.js.map +0 -1
  118. package/dist/chunk-SNINLBEE.js +0 -167
  119. package/dist/chunk-SNINLBEE.js.map +0 -1
  120. package/dist/chunk-UJJPTSEI.cjs.map +0 -1
  121. package/dist/chunk-VYA6QDNA.js.map +0 -1
@@ -46,6 +46,11 @@ export type AttributeRedactorFn = (
46
46
  */
47
47
  export type AttributeRedactorPreset = 'default' | 'strict' | 'pci-dss';
48
48
 
49
+ /**
50
+ * Masker function type - receives the matched string and returns a masked version
51
+ */
52
+ export type MaskFn = (match: string) => string;
53
+
49
54
  /**
50
55
  * Value pattern configuration
51
56
  */
@@ -56,8 +61,15 @@ export interface ValuePatternConfig {
56
61
  pattern: RegExp;
57
62
  /** Custom replacement (default: uses global replacement) */
58
63
  replacement?: string;
64
+ /** Mask function for smart partial masking (overrides replacement) */
65
+ mask?: MaskFn;
59
66
  }
60
67
 
68
+ /**
69
+ * Built-in PII pattern names
70
+ */
71
+ export type BuiltinPatternName = keyof typeof builtinPatterns;
72
+
61
73
  /**
62
74
  * Attribute redactor configuration
63
75
  */
@@ -68,6 +80,15 @@ export interface AttributeRedactorConfig {
68
80
  /** Patterns to match against attribute values (redacts matched portion) */
69
81
  valuePatterns?: ValuePatternConfig[];
70
82
 
83
+ /** Dot-notation paths to redact (e.g. 'user.password', 'payment.card') */
84
+ paths?: string[];
85
+
86
+ /** Built-in PII patterns to enable. `true` enables all, `false` disables all, array selects specific ones. */
87
+ builtins?: boolean | BuiltinPatternName[];
88
+
89
+ /** Custom RegExp patterns for string-level redaction */
90
+ patterns?: RegExp[];
91
+
71
92
  /** Default replacement string (default: '[REDACTED]') */
72
93
  replacement?: string;
73
94
 
@@ -100,14 +121,112 @@ export const REDACTOR_PATTERNS = {
100
121
  /^(password|passwd|pwd|secret|token|api[_-]?key|auth|credential|private[_-]?key|authorization)$/i,
101
122
  } as const;
102
123
 
124
+ /**
125
+ * Built-in PII detection patterns with smart masking.
126
+ * Each builtin preserves just enough signal for debugging while scrubbing PII.
127
+ */
128
+ export const builtinPatterns = {
129
+ /** Credit card numbers → ****1111 (PCI DSS: last 4 allowed) */
130
+ creditCard: {
131
+ pattern: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g,
132
+ mask: (m: string) => `****${m.replace(/[\s-]/g, '').slice(-4)}`,
133
+ },
134
+ /** Email addresses → a***@***.com */
135
+ email: {
136
+ pattern: /[\w.+-]+@[\w-]+\.[\w.]+/g,
137
+ mask: (m: string) => {
138
+ const at = m.indexOf('@');
139
+ if (at < 1) return '***@***';
140
+ const tld = m.slice(m.lastIndexOf('.'));
141
+ return `${m[0]}***@***${tld}`;
142
+ },
143
+ },
144
+ /** IPv4 addresses → ***.***.***.100 (last octet only) */
145
+ ipv4: {
146
+ pattern:
147
+ /\b(?!0\.0\.0\.0\b)(?!127\.0\.0\.1\b)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
148
+ mask: (m: string) => `***.***.***.${m.split('.').pop()}`,
149
+ },
150
+ /** International phone numbers → +33******78 (country code + last 2 digits) */
151
+ phone: {
152
+ pattern:
153
+ /(?:\+\d{1,3}[\s.-]?)?\(?\d{1,4}\)?[\s.-]?\d{2,4}[\s.-]?\d{2,4}[\s.-]?\d{2,4}\b/g,
154
+ mask: (m: string) => {
155
+ const digits = m.replace(/[^\d]/g, '');
156
+ const hasPlus = m.startsWith('+');
157
+ if (hasPlus && digits.length > 4) {
158
+ const ccMatch = m.match(/^\+\d{1,3}/);
159
+ const cc = ccMatch ? ccMatch[0] : '+';
160
+ return `${cc}******${digits.slice(-2)}`;
161
+ }
162
+ if (digits.length > 2) {
163
+ return `${'*'.repeat(digits.length - 2)}${digits.slice(-2)}`;
164
+ }
165
+ return '***';
166
+ },
167
+ },
168
+ /** JWT tokens → eyJ***.*** */
169
+ jwt: {
170
+ pattern: /\beyJ[\w-]*\.[\w-]*\.[\w-]*\b/g,
171
+ mask: () => 'eyJ***.***',
172
+ },
173
+ /** Bearer tokens → Bearer *** */
174
+ bearer: {
175
+ pattern: /\bBearer\s+[\w\-.~+/]{8,}=*/gi,
176
+ mask: () => 'Bearer ***',
177
+ },
178
+ /** IBAN → FR76****189 (country + check digits + last 3) */
179
+ iban: {
180
+ pattern:
181
+ /\b[A-Z]{2}\d{2}[\s-]?[\dA-Z]{4}[\s-]?[\dA-Z]{4}[\s-]?[\dA-Z]{4}[\s-]?[\dA-Z]{0,4}[\s-]?[\dA-Z]{0,4}[\s-]?[\dA-Z]{0,4}\b/g,
182
+ mask: (m: string) => {
183
+ const clean = m.replace(/[\s-]/g, '');
184
+ return `${clean.slice(0, 4)}****${clean.slice(-3)}`;
185
+ },
186
+ },
187
+ } as const;
188
+
189
+ function cloneRegex(re: RegExp): RegExp {
190
+ return new RegExp(re.source, re.flags);
191
+ }
192
+
193
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
194
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
195
+ }
196
+
197
+ function toRegExp(value: unknown): RegExp | undefined {
198
+ if (value instanceof RegExp) return value;
199
+ if (typeof value === 'string') return new RegExp(value, 'g');
200
+ if (isPlainObject(value) && typeof value.source === 'string') {
201
+ const flags = typeof value.flags === 'string' ? value.flags : 'g';
202
+ return new RegExp(value.source, flags);
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ function toRegExpArray(value: unknown): RegExp[] | undefined {
208
+ if (!Array.isArray(value)) return undefined;
209
+ const out: RegExp[] = [];
210
+ for (const item of value) {
211
+ const re = toRegExp(item);
212
+ if (re) out.push(re);
213
+ }
214
+ return out.length > 0 ? out : [];
215
+ }
216
+
217
+ function builtinToValuePattern(name: BuiltinPatternName): ValuePatternConfig {
218
+ const b = builtinPatterns[name];
219
+ return { name, pattern: cloneRegex(b.pattern), mask: b.mask };
220
+ }
221
+
103
222
  /**
104
223
  * Default value patterns for the 'default' preset
105
224
  */
106
225
  const DEFAULT_VALUE_PATTERNS: ValuePatternConfig[] = [
107
- { name: 'email', pattern: REDACTOR_PATTERNS.email },
108
- { name: 'phone', pattern: REDACTOR_PATTERNS.phone },
226
+ builtinToValuePattern('email'),
227
+ builtinToValuePattern('phone'),
109
228
  { name: 'ssn', pattern: REDACTOR_PATTERNS.ssn },
110
- { name: 'creditCard', pattern: REDACTOR_PATTERNS.creditCard },
229
+ builtinToValuePattern('creditCard'),
111
230
  ];
112
231
 
113
232
  /**
@@ -118,61 +237,158 @@ export const REDACTOR_PRESETS: Record<
118
237
  AttributeRedactorConfig
119
238
  > = {
120
239
  /**
121
- * Default preset - covers common PII patterns
122
- * Detects: emails, phone numbers, SSNs, credit cards
240
+ * Default preset - covers common PII patterns with smart masking
241
+ * Detects: emails (a***@***.com), phone numbers, SSNs, credit cards (****1111)
123
242
  * Redacts keys: password, secret, token, apiKey, auth, credential
124
243
  */
125
244
  default: {
126
245
  keyPatterns: [REDACTOR_PATTERNS.sensitiveKey],
127
246
  valuePatterns: DEFAULT_VALUE_PATTERNS,
247
+ builtins: true,
128
248
  replacement: '[REDACTED]',
129
249
  },
130
250
 
131
251
  /**
132
252
  * Strict preset - more aggressive redaction for high-security environments
133
- * Includes everything in default plus: Bearer tokens, JWTs, API keys in values
253
+ * Includes everything in default plus: Bearer tokens, JWTs, IBAN, API keys in values
134
254
  */
135
255
  strict: {
136
256
  keyPatterns: [REDACTOR_PATTERNS.sensitiveKey, /bearer/i, /jwt/i],
137
257
  valuePatterns: [
138
258
  ...DEFAULT_VALUE_PATTERNS,
139
- { name: 'bearerToken', pattern: REDACTOR_PATTERNS.bearerToken },
259
+ builtinToValuePattern('jwt'),
260
+ builtinToValuePattern('bearer'),
261
+ builtinToValuePattern('iban'),
140
262
  { name: 'apiKeyInValue', pattern: REDACTOR_PATTERNS.apiKeyInValue },
141
- { name: 'jwt', pattern: REDACTOR_PATTERNS.jwt },
142
263
  ],
264
+ builtins: true,
143
265
  replacement: '[REDACTED]',
144
266
  },
145
267
 
146
268
  /**
147
269
  * PCI-DSS preset - focused on payment card industry compliance
148
- * Redacts: credit card numbers, CVV-like patterns, card-related keys
270
+ * Redacts: credit card numbers (****1111), CVV-like patterns, card-related keys
149
271
  */
150
272
  'pci-dss': {
151
273
  keyPatterns: [/card/i, /cvv/i, /cvc/i, /pan/i, /expir/i, /ccn/i],
152
- valuePatterns: [
153
- { name: 'creditCard', pattern: REDACTOR_PATTERNS.creditCard },
154
- ],
274
+ valuePatterns: [builtinToValuePattern('creditCard')],
275
+ builtins: ['creditCard'],
155
276
  replacement: '[REDACTED]',
156
277
  },
157
278
  };
158
279
 
280
+ /**
281
+ * Normalize redactor config that may have been deserialized from JSON/YAML.
282
+ * Converts regex-like values back to RegExp instances.
283
+ */
284
+ export function normalizeAttributeRedactorConfig(
285
+ raw: AttributeRedactorConfig | AttributeRedactorPreset | unknown,
286
+ ): AttributeRedactorConfig | AttributeRedactorPreset | undefined {
287
+ if (raw === undefined || raw === null) return undefined;
288
+ if (typeof raw === 'string') return raw as AttributeRedactorPreset;
289
+ if (!isPlainObject(raw)) return undefined;
290
+
291
+ const config: AttributeRedactorConfig = {};
292
+
293
+ if (Array.isArray(raw.paths)) {
294
+ config.paths = raw.paths.filter(
295
+ (value): value is string => typeof value === 'string',
296
+ );
297
+ }
298
+
299
+ if (typeof raw.replacement === 'string') {
300
+ config.replacement = raw.replacement;
301
+ }
302
+
303
+ if (typeof raw.builtins === 'boolean') {
304
+ config.builtins = raw.builtins;
305
+ } else if (Array.isArray(raw.builtins)) {
306
+ config.builtins = raw.builtins.filter(
307
+ (name): name is BuiltinPatternName => typeof name === 'string',
308
+ );
309
+ }
310
+
311
+ if (typeof raw.redactor === 'function') {
312
+ config.redactor = raw.redactor as AttributeRedactorFn;
313
+ }
314
+
315
+ const keyPatterns = toRegExpArray(raw.keyPatterns);
316
+ if (keyPatterns) config.keyPatterns = keyPatterns;
317
+
318
+ const patterns = toRegExpArray(raw.patterns);
319
+ if (patterns) config.patterns = patterns;
320
+
321
+ if (Array.isArray(raw.valuePatterns)) {
322
+ const valuePatterns: ValuePatternConfig[] = [];
323
+ for (const item of raw.valuePatterns) {
324
+ if (!isPlainObject(item) || typeof item.name !== 'string') continue;
325
+ const pattern = toRegExp(item.pattern);
326
+ if (!pattern) continue;
327
+ valuePatterns.push({
328
+ name: item.name,
329
+ pattern,
330
+ replacement:
331
+ typeof item.replacement === 'string' ? item.replacement : undefined,
332
+ mask:
333
+ typeof item.mask === 'function' ? (item.mask as MaskFn) : undefined,
334
+ });
335
+ }
336
+ config.valuePatterns = valuePatterns;
337
+ }
338
+
339
+ return config;
340
+ }
341
+
159
342
  /**
160
343
  * Resolve config to a normalized form
161
344
  */
162
345
  function resolveConfig(
163
346
  config: AttributeRedactorConfig | AttributeRedactorPreset,
164
347
  ): AttributeRedactorConfig {
165
- if (typeof config === 'string') {
166
- const preset = REDACTOR_PRESETS[config];
348
+ const normalized = normalizeAttributeRedactorConfig(config);
349
+ if (!normalized) {
350
+ throw new Error('Invalid attribute redactor config');
351
+ }
352
+
353
+ if (typeof normalized === 'string') {
354
+ const preset = REDACTOR_PRESETS[normalized];
167
355
  if (!preset) {
168
356
  throw new Error(
169
- `Unknown attribute redactor preset: "${config}". ` +
357
+ `Unknown attribute redactor preset: "${normalized}". ` +
170
358
  `Available presets: ${Object.keys(REDACTOR_PRESETS).join(', ')}`,
171
359
  );
172
360
  }
173
361
  return preset;
174
362
  }
175
- return config;
363
+
364
+ const resolvedConfig: AttributeRedactorConfig = {
365
+ ...normalized,
366
+ keyPatterns: normalized.keyPatterns
367
+ ? [...normalized.keyPatterns]
368
+ : undefined,
369
+ valuePatterns: normalized.valuePatterns
370
+ ? [...normalized.valuePatterns]
371
+ : undefined,
372
+ paths: normalized.paths ? [...normalized.paths] : undefined,
373
+ patterns: normalized.patterns ? [...normalized.patterns] : undefined,
374
+ };
375
+
376
+ // Merge built-in patterns if enabled
377
+ if (resolvedConfig.builtins !== false) {
378
+ const builtinNames = Array.isArray(resolvedConfig.builtins)
379
+ ? resolvedConfig.builtins
380
+ : (Object.keys(builtinPatterns) as BuiltinPatternName[]);
381
+ const builtinValuePatterns = builtinNames
382
+ .filter((name) => name in builtinPatterns)
383
+ .map(builtinToValuePattern);
384
+
385
+ resolvedConfig.valuePatterns = [
386
+ ...(resolvedConfig.valuePatterns ?? []),
387
+ ...builtinValuePatterns,
388
+ ];
389
+ }
390
+
391
+ return resolvedConfig;
176
392
  }
177
393
 
178
394
  /**
@@ -188,27 +404,40 @@ function createRedactorFromConfig(
188
404
 
189
405
  const keyPatterns = config.keyPatterns ?? [];
190
406
  const valuePatterns = config.valuePatterns ?? [];
407
+ const paths = config.paths ?? [];
408
+ const pathSet = new Set(paths);
409
+ const customPatterns = config.patterns ?? [];
191
410
  const defaultReplacement = config.replacement ?? '[REDACTED]';
192
411
 
412
+ // Build masker list from valuePatterns that have mask functions
413
+ const maskers: [RegExp, MaskFn][] = valuePatterns
414
+ .filter((vp) => vp.mask)
415
+ .map((vp) => [cloneRegex(vp.pattern), vp.mask!]);
416
+
193
417
  return (key: string, value: AttributeValue): AttributeValue => {
194
418
  // Check if key matches any sensitive key pattern
195
419
  for (const pattern of keyPatterns) {
196
- // Reset lastIndex for global regexes
197
420
  pattern.lastIndex = 0;
198
421
  if (pattern.test(key)) {
199
422
  return defaultReplacement;
200
423
  }
201
424
  }
202
425
 
203
- // For non-string values, return as-is (can't pattern match)
426
+ // Check if key matches any path-based redaction
427
+ if (pathSet.has(key)) {
428
+ return defaultReplacement;
429
+ }
430
+
431
+ // For non-string values, return as-is
204
432
  if (typeof value !== 'string') {
205
- // Handle arrays of strings
206
433
  if (Array.isArray(value)) {
207
434
  return value.map((item) => {
208
435
  if (typeof item === 'string') {
209
436
  return redactStringValue(
210
437
  item,
211
438
  valuePatterns,
439
+ maskers,
440
+ customPatterns,
212
441
  defaultReplacement,
213
442
  ) as string;
214
443
  }
@@ -218,25 +447,50 @@ function createRedactorFromConfig(
218
447
  return value;
219
448
  }
220
449
 
221
- // Apply value patterns to string values
222
- return redactStringValue(value, valuePatterns, defaultReplacement);
450
+ // Three-tier strategy: path-based masker-based → pattern-based
451
+ return redactStringValue(
452
+ value,
453
+ valuePatterns,
454
+ maskers,
455
+ customPatterns,
456
+ defaultReplacement,
457
+ );
223
458
  };
224
459
  }
225
460
 
226
461
  /**
227
- * Apply value patterns to a string
462
+ * Apply three-tier redaction strategy to a string
463
+ * 1. Masker-based: built-in patterns with smart partial masking
464
+ * 2. Pattern-based: custom RegExp patterns replaced with replacement
228
465
  */
229
466
  function redactStringValue(
230
467
  value: string,
231
468
  patterns: ValuePatternConfig[],
469
+ maskers: [RegExp, MaskFn][],
470
+ customPatterns: RegExp[],
232
471
  defaultReplacement: string,
233
472
  ): string {
234
473
  let result = value;
235
- for (const { pattern, replacement } of patterns) {
236
- // Reset lastIndex for global regexes
474
+
475
+ // Tier 1: Apply maskers (smart partial masking)
476
+ for (const [pattern, mask] of maskers) {
477
+ pattern.lastIndex = 0;
478
+ result = result.replace(pattern, mask);
479
+ }
480
+
481
+ // Tier 2: Apply value patterns without mask (full replacement)
482
+ for (const { pattern, replacement, mask } of patterns) {
483
+ if (mask) continue; // Already handled by maskers
237
484
  pattern.lastIndex = 0;
238
485
  result = result.replaceAll(pattern, replacement ?? defaultReplacement);
239
486
  }
487
+
488
+ // Tier 3: Apply custom patterns
489
+ for (const pattern of customPatterns) {
490
+ pattern.lastIndex = 0;
491
+ result = result.replaceAll(pattern, defaultReplacement);
492
+ }
493
+
240
494
  return result;
241
495
  }
242
496
 
@@ -232,7 +232,7 @@ export function createBuiltinLogger(
232
232
  log(level, extraOrMessage, message as Record<string, unknown>);
233
233
  } else {
234
234
  // Pure string-only call: logger.info('message')
235
- log(level, extraOrMessage, undefined);
235
+ log(level, extraOrMessage);
236
236
  }
237
237
  } else {
238
238
  // Pino style: logger.info({ extra }, 'message')
@@ -303,7 +303,7 @@ export function createBuiltinLogger(
303
303
  }
304
304
 
305
305
  // Pure string-only call: logger.error('message')
306
- log('error', extraOrMessage, undefined);
306
+ log('error', extraOrMessage);
307
307
  return;
308
308
  }
309
309
 
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { TraceContext } from './trace-context';
3
+ import {
4
+ recordPromptSent,
5
+ recordResponseReceived,
6
+ recordRetry,
7
+ recordStreamFirstToken,
8
+ recordToolCall,
9
+ } from './gen-ai-events';
10
+
11
+ type CapturedEvent = { name: string; attrs?: Record<string, unknown> };
12
+
13
+ function captureCtx(): {
14
+ ctx: TraceContext;
15
+ events: CapturedEvent[];
16
+ } {
17
+ const events: CapturedEvent[] = [];
18
+ const ctx = {
19
+ addEvent: (name: string, attrs?: Record<string, unknown>) => {
20
+ events.push({ name, attrs });
21
+ },
22
+ setAttribute: () => {},
23
+ setAttributes: () => {},
24
+ setStatus: () => {},
25
+ recordException: () => {},
26
+ addLink: () => {},
27
+ addLinks: () => {},
28
+ updateName: () => {},
29
+ isRecording: () => true,
30
+ end: () => {},
31
+ } as unknown as TraceContext;
32
+ return { ctx, events };
33
+ }
34
+
35
+ describe('GenAI span event helpers', () => {
36
+ it('recordPromptSent emits gen_ai.prompt.sent with canonical attrs', () => {
37
+ const { ctx, events } = captureCtx();
38
+ recordPromptSent(ctx, {
39
+ model: 'gpt-4o',
40
+ promptTokens: 1200,
41
+ messageCount: 3,
42
+ operation: 'chat',
43
+ });
44
+ expect(events).toHaveLength(1);
45
+ expect(events[0]).toEqual({
46
+ name: 'gen_ai.prompt.sent',
47
+ attrs: {
48
+ 'gen_ai.request.model': 'gpt-4o',
49
+ 'gen_ai.usage.input_tokens': 1200,
50
+ 'gen_ai.request.message_count': 3,
51
+ 'gen_ai.operation.name': 'chat',
52
+ },
53
+ });
54
+ });
55
+
56
+ it('recordPromptSent omits unset fields rather than writing undefined', () => {
57
+ const { ctx, events } = captureCtx();
58
+ recordPromptSent(ctx);
59
+ expect(events[0]?.attrs).toEqual({});
60
+ });
61
+
62
+ it('recordResponseReceived joins finish reasons into a CSV for attribute compat', () => {
63
+ const { ctx, events } = captureCtx();
64
+ recordResponseReceived(ctx, {
65
+ model: 'gpt-4o-2024-11-20',
66
+ promptTokens: 1200,
67
+ completionTokens: 400,
68
+ totalTokens: 1600,
69
+ finishReasons: ['stop', 'tool_calls'],
70
+ });
71
+ expect(events[0]).toEqual({
72
+ name: 'gen_ai.response.received',
73
+ attrs: {
74
+ 'gen_ai.response.model': 'gpt-4o-2024-11-20',
75
+ 'gen_ai.usage.input_tokens': 1200,
76
+ 'gen_ai.usage.output_tokens': 400,
77
+ 'gen_ai.usage.total_tokens': 1600,
78
+ 'gen_ai.response.finish_reasons': 'stop,tool_calls',
79
+ },
80
+ });
81
+ });
82
+
83
+ it('recordResponseReceived omits finish_reasons when empty', () => {
84
+ const { ctx, events } = captureCtx();
85
+ recordResponseReceived(ctx, { model: 'claude-sonnet-4-6' });
86
+ expect(events[0]?.attrs).not.toHaveProperty(
87
+ 'gen_ai.response.finish_reasons',
88
+ );
89
+ });
90
+
91
+ it('recordRetry captures attempt, reason, delay, and status code', () => {
92
+ const { ctx, events } = captureCtx();
93
+ recordRetry(ctx, {
94
+ attempt: 2,
95
+ reason: 'rate_limit',
96
+ delayMs: 1000,
97
+ statusCode: 429,
98
+ });
99
+ expect(events[0]).toEqual({
100
+ name: 'gen_ai.retry',
101
+ attrs: {
102
+ 'retry.attempt': 2,
103
+ 'retry.reason': 'rate_limit',
104
+ 'retry.delay_ms': 1000,
105
+ 'http.response.status_code': 429,
106
+ },
107
+ });
108
+ });
109
+
110
+ it('recordToolCall writes canonical gen_ai.tool.* keys', () => {
111
+ const { ctx, events } = captureCtx();
112
+ recordToolCall(ctx, {
113
+ toolName: 'search_traces',
114
+ toolCallId: 'call-123',
115
+ arguments: '{"serviceName":"api"}',
116
+ });
117
+ expect(events[0]).toEqual({
118
+ name: 'gen_ai.tool.call',
119
+ attrs: {
120
+ 'gen_ai.tool.name': 'search_traces',
121
+ 'gen_ai.tool.call.id': 'call-123',
122
+ 'gen_ai.tool.arguments': '{"serviceName":"api"}',
123
+ },
124
+ });
125
+ });
126
+
127
+ it('recordStreamFirstToken is the bare marker for TTFT', () => {
128
+ const { ctx, events } = captureCtx();
129
+ recordStreamFirstToken(ctx, { tokensSoFar: 1 });
130
+ expect(events[0]).toEqual({
131
+ name: 'gen_ai.stream.first_token',
132
+ attrs: { 'gen_ai.stream.tokens_so_far': 1 },
133
+ });
134
+ });
135
+ });