@reproapp/node-sdk 0.0.1 → 0.0.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.
package/src/index.ts ADDED
@@ -0,0 +1,2851 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ import type { Schema, Model, Query } from 'mongoose';
3
+ import * as mongoose from 'mongoose';
4
+ import { AsyncLocalStorage } from 'async_hooks';
5
+
6
+ /**
7
+ * ============================= IMPORTANT =============================
8
+ * We snapshot Mongoose core methods BEFORE tracer init, then re-apply them
9
+ * AFTER tracer init. This guarantees Model.find() still returns a Query and
10
+ * chainability (e.g., .sort().lean()) is preserved. No behavior changes.
11
+ * =====================================================================
12
+ */
13
+ const __ORIG = (() => {
14
+ const M: any = (mongoose as any).Model;
15
+ const Qp: any = (mongoose as any).Query?.prototype;
16
+ const Ap: any = (mongoose as any).Aggregate?.prototype;
17
+ return {
18
+ Model: {
19
+ find: M?.find,
20
+ findOne: M?.findOne,
21
+ findById: M?.findById,
22
+ create: M?.create,
23
+ insertMany: M?.insertMany,
24
+ updateOne: M?.updateOne,
25
+ updateMany: M?.updateMany,
26
+ replaceOne: M?.replaceOne,
27
+ deleteOne: M?.deleteOne,
28
+ deleteMany: M?.deleteMany,
29
+ countDocuments: M?.countDocuments,
30
+ estimatedDocumentCount: M?.estimatedDocumentCount,
31
+ distinct: M?.distinct,
32
+ findOneAndUpdate: M?.findOneAndUpdate,
33
+ findOneAndDelete: M?.findOneAndDelete,
34
+ findOneAndReplace: M?.findOneAndReplace,
35
+ findOneAndRemove: M?.findOneAndRemove,
36
+ bulkWrite: M?.bulkWrite,
37
+ },
38
+ Query: {
39
+ exec: Qp?.exec,
40
+ lean: Qp?.lean,
41
+ sort: Qp?.sort,
42
+ select: Qp?.select,
43
+ limit: Qp?.limit,
44
+ skip: Qp?.skip,
45
+ populate: Qp?.populate,
46
+ getFilter: Qp?.getFilter,
47
+ getUpdate: Qp?.getUpdate,
48
+ getOptions: Qp?.getOptions,
49
+ projection: Qp?.projection,
50
+ },
51
+ Aggregate: {
52
+ exec: Ap?.exec,
53
+ }
54
+ };
55
+ })();
56
+
57
+ function restoreMongooseIfNeeded() {
58
+ try {
59
+ const M: any = (mongoose as any).Model;
60
+ const Qp: any = (mongoose as any).Query?.prototype;
61
+ const Ap: any = (mongoose as any).Aggregate?.prototype;
62
+ const safeSet = (obj: any, key: string, val: any) => {
63
+ if (!obj || !val) return;
64
+ if (obj[key] !== val) { try { obj[key] = val; } catch {} }
65
+ };
66
+
67
+ // Restore Model methods (chainability depends on these returning Query)
68
+ Object.entries(__ORIG.Model).forEach(([k, v]) => safeSet(M, k, v));
69
+
70
+ // Restore Query prototype essentials (exec/lean/etc.)
71
+ Object.entries(__ORIG.Query).forEach(([k, v]) => safeSet(Qp, k, v));
72
+
73
+ // Restore Aggregate exec
74
+ Object.entries(__ORIG.Aggregate).forEach(([k, v]) => safeSet(Ap, k, v));
75
+ } catch {}
76
+ }
77
+
78
+ function flushQueryFinalizers(query: any, value: any, threw: boolean, error: any) {
79
+ try {
80
+ const callbacks = (query as any)?.__repro_query_finalizers;
81
+ if (!Array.isArray(callbacks) || callbacks.length === 0) return;
82
+ (query as any).__repro_query_finalizers = [];
83
+ for (const fn of callbacks) {
84
+ try { fn(value, threw, error); } catch {}
85
+ }
86
+ } catch {}
87
+ }
88
+
89
+ function patchMongooseExecCapture(targetMongoose: any = mongoose) {
90
+ // Patch Query.prototype.exec to capture resolved results for trace return values.
91
+ try {
92
+ const Qp: any = targetMongoose?.Query?.prototype;
93
+ if (Qp && !Qp.__repro_exec_patched) {
94
+ const origExec = Qp.exec;
95
+ if (typeof origExec === 'function') {
96
+ Qp.__repro_exec_patched = true;
97
+ Qp.exec = function reproPatchedExec(this: any, ...args: any[]) {
98
+ try {
99
+ (this as any).__repro_is_query = true;
100
+ const ctx = __TRACER__?.getCurrentSpanContext?.();
101
+ const traceId = ctx?.traceId ?? __TRACER__?.getCurrentTraceId?.() ?? null;
102
+ if (ctx || traceId) {
103
+ Object.defineProperty(this, '__repro_span_context', {
104
+ value: {
105
+ traceId,
106
+ spanId: ctx?.spanId ?? null,
107
+ parentSpanId: ctx?.parentSpanId ?? null,
108
+ depth: ctx?.depth ?? null,
109
+ },
110
+ configurable: true,
111
+ writable: true,
112
+ enumerable: false,
113
+ });
114
+ }
115
+ } catch {}
116
+ const p = origExec.apply(this, args);
117
+ try {
118
+ if (p && typeof p.then === 'function') {
119
+ this.__repro_result_promise = p;
120
+ p.then(
121
+ (res: any) => {
122
+ try { this.__repro_result = res; } catch {}
123
+ flushQueryFinalizers(this, res, false, null);
124
+ return res;
125
+ },
126
+ (err: any) => {
127
+ flushQueryFinalizers(this, undefined, true, err);
128
+ return err;
129
+ }
130
+ );
131
+ }
132
+ } catch {}
133
+ return p;
134
+ };
135
+ }
136
+ }
137
+ } catch {}
138
+
139
+ // Patch Aggregate.prototype.exec as well so aggregation pipelines surface their results in traces.
140
+ try {
141
+ const Ap: any = targetMongoose?.Aggregate?.prototype;
142
+ if (Ap && !Ap.__repro_agg_exec_patched) {
143
+ const origAggExec = Ap.exec;
144
+ if (typeof origAggExec === 'function') {
145
+ Ap.__repro_agg_exec_patched = true;
146
+ Ap.exec = function reproPatchedAggExec(this: any, ...args: any[]) {
147
+ try {
148
+ (this as any).__repro_is_query = true;
149
+ const ctx = __TRACER__?.getCurrentSpanContext?.();
150
+ const traceId = ctx?.traceId ?? __TRACER__?.getCurrentTraceId?.() ?? null;
151
+ if (ctx || traceId) {
152
+ Object.defineProperty(this, '__repro_span_context', {
153
+ value: {
154
+ traceId,
155
+ spanId: ctx?.spanId ?? null,
156
+ parentSpanId: ctx?.parentSpanId ?? null,
157
+ depth: ctx?.depth ?? null,
158
+ },
159
+ configurable: true,
160
+ writable: true,
161
+ enumerable: false,
162
+ });
163
+ }
164
+ } catch {}
165
+ const p = origAggExec.apply(this, args);
166
+ try {
167
+ if (p && typeof p.then === 'function') {
168
+ this.__repro_result_promise = p;
169
+ p.then(
170
+ (res: any) => {
171
+ try { this.__repro_result = res; } catch {}
172
+ flushQueryFinalizers(this, res, false, null);
173
+ return res;
174
+ },
175
+ (err: any) => {
176
+ flushQueryFinalizers(this, undefined, true, err);
177
+ return err;
178
+ }
179
+ );
180
+ }
181
+ } catch {}
182
+ return p;
183
+ };
184
+ }
185
+ }
186
+ } catch {}
187
+ }
188
+
189
+ function patchAllKnownMongooseInstances() {
190
+ // Patch the SDK's bundled mongoose first.
191
+ patchMongooseExecCapture(mongoose as any);
192
+
193
+ // Also patch any other mongoose copies the host app might have installed (e.g., different versions).
194
+ try {
195
+ const cache = (require as any)?.cache || {};
196
+ const seen = new Set<any>();
197
+ Object.keys(cache).forEach((key) => {
198
+ if (!/node_modules[\\/](mongoose)[\\/]/i.test(key)) return;
199
+ const mod = cache[key];
200
+ const exp = mod?.exports;
201
+ if (!exp || seen.has(exp)) return;
202
+ seen.add(exp);
203
+ patchMongooseExecCapture(exp);
204
+ });
205
+ } catch {}
206
+ }
207
+
208
+ // ====== tiny, safe tracer auto-init (no node_modules patches) ======
209
+ type SpanContext = {
210
+ traceId: string | null;
211
+ spanId: string | number | null;
212
+ parentSpanId: string | number | null;
213
+ depth: number | null;
214
+ };
215
+
216
+ type TracerApi = {
217
+ init?: (opts: any) => void;
218
+ tracer?: { on: (fn: (ev: any) => void) => () => void };
219
+ getCurrentTraceId?: () => string | null;
220
+ getCurrentSpanContext?: () => SpanContext | null;
221
+ patchHttp?: () => void; // optional in your tracer
222
+ setFunctionLogsEnabled?: (enabled: boolean) => void;
223
+ };
224
+
225
+ const REQUEST_START_HEADER = 'x-bug-request-start';
226
+
227
+ function escapeRx(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
228
+ let __TRACER__: TracerApi | null = null;
229
+ let __TRACER_READY = false;
230
+
231
+ type TraceEventPhase = 'enter' | 'exit';
232
+ export type TraceRulePattern = string | number | RegExp | Array<string | number | RegExp>;
233
+
234
+ export type TraceEventForFilter = {
235
+ type: TraceEventPhase; // legacy alias for eventType
236
+ eventType: TraceEventPhase;
237
+ functionType?: string | null;
238
+ fn?: string;
239
+ wrapperClass?: string | null;
240
+ file?: string | null;
241
+ line?: number | null;
242
+ depth?: number;
243
+ library?: string | null;
244
+ };
245
+
246
+ type TraceEventRecord = {
247
+ t: number;
248
+ type: 'enter' | 'exit';
249
+ functionType?: string | null;
250
+ fn?: string;
251
+ file?: string;
252
+ line?: number | null;
253
+ depth?: number;
254
+ spanId?: string | number | null;
255
+ parentSpanId?: string | number | null;
256
+ args?: any;
257
+ returnValue?: any;
258
+ threw?: boolean;
259
+ error?: any;
260
+ unawaited?: boolean;
261
+ };
262
+
263
+ type EndpointTraceInfo = {
264
+ fn: string | null;
265
+ file: string | null;
266
+ line: number | null;
267
+ functionType: string | null;
268
+ };
269
+
270
+ export type HeaderRule = string | RegExp;
271
+ export type HeaderCaptureOptions = {
272
+ /** When true, sensitive headers such as Authorization are kept unmasked; default masks them. */
273
+ allowSensitiveHeaders?: boolean;
274
+ /**
275
+ * Header names (string or RegExp) to mask.
276
+ * `dropHeaders` is kept for backward-compatibility and treated as an alias for `maskHeaders`.
277
+ */
278
+ maskHeaders?: HeaderRule | HeaderRule[];
279
+ /** @deprecated Alias for {@link maskHeaders}. */
280
+ dropHeaders?: HeaderRule | HeaderRule[];
281
+ /**
282
+ * Header names (string or RegExp) to keep unmasked, overriding defaults and `maskHeaders`.
283
+ * `keepHeaders` is kept for backward-compatibility and treated as an alias for `unmaskHeaders`.
284
+ */
285
+ unmaskHeaders?: HeaderRule | HeaderRule[];
286
+ /** @deprecated Alias for {@link unmaskHeaders}. */
287
+ keepHeaders?: HeaderRule | HeaderRule[];
288
+ };
289
+
290
+ type NormalizedHeaderCapture = {
291
+ enabled: boolean;
292
+ allowSensitive: boolean;
293
+ mask: HeaderRule[];
294
+ unmask: HeaderRule[];
295
+ };
296
+
297
+ /** Lightweight helper to disable every trace emitted from specific files. */
298
+ export interface DisableTraceByFilename {
299
+ file: TraceRulePattern;
300
+ }
301
+
302
+ type DisableTraceFileConfig = TraceRulePattern | DisableTraceByFilename | null | undefined;
303
+
304
+ /**
305
+ * Declarative rule that disables trace events matching the provided patterns.
306
+ *
307
+ * Each property accepts a string (substring match), a RegExp, or an array of
308
+ * either. When an array is provided, a single match of any entry is enough to
309
+ * drop the event. Provide no value to leave that dimension unrestricted.
310
+ */
311
+ export type DisableFunctionTraceRule = {
312
+ /** Shortcut for {@link functionName}. */
313
+ fn?: TraceRulePattern;
314
+ /** Function name (e.g. `"findOne"`, `/^UserService\./`). */
315
+ functionName?: TraceRulePattern;
316
+ /** Shortcut for {@link wrapperClass}. */
317
+ wrapper?: TraceRulePattern;
318
+ /**
319
+ * Wrapper/owner name derived from {@link functionName} (e.g. `"UserService"` in `"UserService.create"`).
320
+ * Useful when multiple functions share the same method name.
321
+ */
322
+ wrapperClass?: TraceRulePattern;
323
+ /** Alias for {@link wrapperClass}. */
324
+ className?: TraceRulePattern;
325
+ /** Alias for {@link wrapperClass}. */
326
+ owner?: TraceRulePattern;
327
+ /** Source filename reported by the trace event. */
328
+ file?: TraceRulePattern;
329
+ /** Line number reported by the trace event. */
330
+ line?: TraceRulePattern;
331
+ /** Shortcut for {@link library}. */
332
+ lib?: TraceRulePattern;
333
+ /** Library/package name inferred from the file path (e.g. `"mongoose"`). */
334
+ library?: TraceRulePattern;
335
+ /** Shortcut for {@link functionType}. */
336
+ type?: TraceRulePattern;
337
+ /** Function classification such as `"constructor"`, `"method"`, or `"arrow"`. */
338
+ functionType?: TraceRulePattern;
339
+ /** Shortcut for {@link eventType}. */
340
+ event?: TraceRulePattern;
341
+ /** Trace phase to filter (`"enter"` or `"exit"`). */
342
+ eventType?: TraceRulePattern;
343
+ };
344
+
345
+ export type DisableFunctionTracePredicate = (event: TraceEventForFilter) => boolean;
346
+
347
+ export type DisableFunctionTraceConfig =
348
+ | DisableFunctionTraceRule
349
+ | DisableFunctionTracePredicate;
350
+
351
+ export type ReproMaskTarget =
352
+ | 'request.headers'
353
+ | 'request.body'
354
+ | 'request.params'
355
+ | 'request.query'
356
+ | 'response.body'
357
+ | 'trace.args'
358
+ | 'trace.returnValue'
359
+ | 'trace.error';
360
+
361
+ export type ReproMaskWhen = DisableFunctionTraceRule & {
362
+ /** Match HTTP method (e.g. `"GET"`, `/^post$/i`). */
363
+ method?: TraceRulePattern;
364
+ /** Match request path without query string (e.g. `"/api/auth/login"`). */
365
+ path?: TraceRulePattern;
366
+ /** Match normalized endpoint key (e.g. `"POST /api/auth/login"`). */
367
+ key?: TraceRulePattern;
368
+ };
369
+
370
+ export type ReproMaskRule = {
371
+ when?: ReproMaskWhen;
372
+ target: ReproMaskTarget | ReproMaskTarget[];
373
+ /**
374
+ * Dot/bracket paths to mask (supports `*`, `[0]`, `[*]`).
375
+ * Examples: `"password"`, `"user.token"`, `"items[*].serial"`, `"0.password"` (for trace args arrays).
376
+ */
377
+ paths?: string | string[];
378
+ /** Key name patterns to mask anywhere in the payload. */
379
+ keys?: TraceRulePattern;
380
+ /** Override replacement value for this rule (defaults to config replacement / `"[REDACTED]"`). */
381
+ replacement?: any;
382
+ };
383
+
384
+ export type ReproMaskingConfig = {
385
+ /** Default replacement value (defaults to `"[REDACTED]"`). */
386
+ replacement?: any;
387
+ rules?: ReproMaskRule[] | null;
388
+ };
389
+
390
+ const DEFAULT_INTERCEPTOR_TRACE_RULES: DisableFunctionTraceConfig[] = [
391
+ { fn: /switchToHttp$/i },
392
+ { fn: /intercept$/i },
393
+ ];
394
+
395
+ let interceptorTracingEnabled = false;
396
+ let userDisabledFunctionTraceRules: DisableFunctionTraceConfig[] | null = null;
397
+
398
+ function computeDisabledFunctionTraceRules(
399
+ rules?: DisableFunctionTraceConfig[] | null,
400
+ ): DisableFunctionTraceConfig[] {
401
+ const normalized = Array.isArray(rules)
402
+ ? rules.filter((rule): rule is DisableFunctionTraceConfig => !!rule)
403
+ : [];
404
+ if (interceptorTracingEnabled) {
405
+ return normalized;
406
+ }
407
+ return [...DEFAULT_INTERCEPTOR_TRACE_RULES, ...normalized];
408
+ }
409
+
410
+ function refreshDisabledFunctionTraceRules() {
411
+ disabledFunctionTraceRules = computeDisabledFunctionTraceRules(userDisabledFunctionTraceRules);
412
+ }
413
+
414
+ let disabledFunctionTraceRules: DisableFunctionTraceConfig[] = computeDisabledFunctionTraceRules();
415
+ let disabledFunctionTypePatterns: Array<string | number | RegExp> = [];
416
+ let disabledTraceFilePatterns: Array<string | number | RegExp> = [];
417
+ let __TRACE_LOG_PREF: boolean | null = null;
418
+
419
+ function setInterceptorTracingEnabled(enabled: boolean) {
420
+ const next = !!enabled;
421
+ if (interceptorTracingEnabled === next) return;
422
+ interceptorTracingEnabled = next;
423
+ refreshDisabledFunctionTraceRules();
424
+ }
425
+
426
+ function hasOwn(obj: unknown, key: string): boolean {
427
+ return !!obj && Object.prototype.hasOwnProperty.call(obj, key);
428
+ }
429
+
430
+ function normalizePatternArray<T>(pattern?: T | T[] | null): Exclude<T, null | undefined>[] {
431
+ if (pattern === undefined || pattern === null) return [];
432
+ const arr = Array.isArray(pattern) ? pattern : [pattern];
433
+ return arr.filter((entry): entry is Exclude<T, null | undefined> => entry !== undefined && entry !== null);
434
+ }
435
+
436
+ function matchesPattern(
437
+ value: string | null | undefined,
438
+ pattern?: TraceRulePattern,
439
+ defaultWhenEmpty: boolean = true,
440
+ ): boolean {
441
+ if (pattern === undefined || pattern === null) return defaultWhenEmpty;
442
+ const val = value == null ? '' : String(value);
443
+ const candidates = normalizePatternArray(pattern);
444
+ if (!candidates.length) return defaultWhenEmpty;
445
+ return candidates.some(entry => {
446
+ if (entry instanceof RegExp) {
447
+ try { return entry.test(val); } catch { return false; }
448
+ }
449
+ const needle = String(entry).toLowerCase();
450
+ if (!needle) return val === '';
451
+ return val.toLowerCase().includes(needle);
452
+ });
453
+ }
454
+
455
+ function inferLibraryNameFromFile(file?: string | null): string | null {
456
+ if (!file) return null;
457
+ const normalized = String(file).replace(/\\/g, '/');
458
+ const idx = normalized.lastIndexOf('/node_modules/');
459
+ if (idx === -1) return null;
460
+ const remainder = normalized.slice(idx + '/node_modules/'.length);
461
+ if (!remainder) return null;
462
+ const segments = remainder.split('/');
463
+ if (!segments.length) return null;
464
+ if (segments[0].startsWith('@') && segments.length >= 2) {
465
+ return `${segments[0]}/${segments[1]}`;
466
+ }
467
+ return segments[0] || null;
468
+ }
469
+
470
+ function inferWrapperClassFromFn(fn?: string | null): string | null {
471
+ if (!fn) return null;
472
+ const raw = String(fn);
473
+ if (!raw) return null;
474
+ const hashIdx = raw.indexOf('#');
475
+ if (hashIdx > 0) {
476
+ const left = raw.slice(0, hashIdx).trim();
477
+ return left || null;
478
+ }
479
+ const dotIdx = raw.lastIndexOf('.');
480
+ if (dotIdx > 0) {
481
+ const left = raw.slice(0, dotIdx).trim();
482
+ return left || null;
483
+ }
484
+ return null;
485
+ }
486
+
487
+ function matchesRule(rule: DisableFunctionTraceRule, event: TraceEventForFilter): boolean {
488
+ const namePattern = rule.fn ?? rule.functionName;
489
+ if (!matchesPattern(event.fn, namePattern)) return false;
490
+
491
+ const wrapperPattern = rule.wrapper ?? rule.wrapperClass ?? rule.className ?? rule.owner;
492
+ if (!matchesPattern(event.wrapperClass, wrapperPattern)) return false;
493
+
494
+ if (!matchesPattern(event.file, rule.file)) return false;
495
+ if (!matchesPattern(event.line == null ? null : String(event.line), rule.line)) return false;
496
+
497
+ const libPattern = rule.lib ?? rule.library;
498
+ if (!matchesPattern(event.library, libPattern)) return false;
499
+
500
+ const fnTypePattern = rule.functionType ?? rule.type;
501
+ if (!matchesPattern(event.functionType, fnTypePattern)) return false;
502
+
503
+ const eventTypePattern = rule.eventType ?? rule.event;
504
+ if (!matchesPattern(event.eventType, eventTypePattern)) return false;
505
+
506
+ return true;
507
+ }
508
+
509
+ function shouldDropTraceEvent(event: TraceEventForFilter): boolean {
510
+ if (disabledTraceFilePatterns.length) {
511
+ if (matchesPattern(event.file, disabledTraceFilePatterns, false)) {
512
+ return true;
513
+ }
514
+ }
515
+ if (disabledFunctionTypePatterns.length) {
516
+ if (matchesPattern(event.functionType, disabledFunctionTypePatterns, false)) {
517
+ return true;
518
+ }
519
+ }
520
+ if (!disabledFunctionTraceRules.length) return false;
521
+ for (const rule of disabledFunctionTraceRules) {
522
+ try {
523
+ if (typeof rule === 'function') {
524
+ if (rule(event)) return true;
525
+ } else if (matchesRule(rule, event)) {
526
+ return true;
527
+ }
528
+ } catch {
529
+ // ignore user filter errors
530
+ }
531
+ }
532
+ return false;
533
+ }
534
+
535
+ function shouldPropagateUserDisabledFunctionTrace(event: TraceEventForFilter): boolean {
536
+ const rules = userDisabledFunctionTraceRules;
537
+ if (!rules || rules.length === 0) return false;
538
+ for (const rule of rules) {
539
+ try {
540
+ if (typeof rule === 'function') {
541
+ const enterEvent = { ...event, type: 'enter', eventType: 'enter' } as TraceEventForFilter;
542
+ const exitEvent = { ...event, type: 'exit', eventType: 'exit' } as TraceEventForFilter;
543
+ if (rule(enterEvent) && rule(exitEvent)) return true;
544
+ } else if (matchesRule(rule, event)) {
545
+ const eventTypePattern = rule.eventType ?? rule.event;
546
+ if (!matchesPattern('enter', eventTypePattern)) continue;
547
+ if (!matchesPattern('exit', eventTypePattern)) continue;
548
+ return true;
549
+ }
550
+ } catch {
551
+ // ignore user filter errors
552
+ }
553
+ }
554
+ return false;
555
+ }
556
+
557
+ export function setDisabledFunctionTraces(rules?: DisableFunctionTraceConfig[] | null) {
558
+ if (!rules || !Array.isArray(rules)) {
559
+ userDisabledFunctionTraceRules = null;
560
+ } else {
561
+ userDisabledFunctionTraceRules = rules.filter((rule): rule is DisableFunctionTraceConfig => !!rule);
562
+ }
563
+ refreshDisabledFunctionTraceRules();
564
+ }
565
+
566
+ export function setDisabledFunctionTypes(patterns?: TraceRulePattern | null) {
567
+ disabledFunctionTypePatterns = normalizePatternArray(patterns);
568
+ }
569
+
570
+ function flattenTraceFilePatterns(config: DisableTraceFileConfig): Array<string | number | RegExp> {
571
+ if (config === null || config === undefined) return [];
572
+ if (Array.isArray(config)) {
573
+ return config.flatMap(entry => flattenTraceFilePatterns(entry));
574
+ }
575
+ if (config instanceof RegExp || typeof config === 'string' || typeof config === 'number') {
576
+ return [config];
577
+ }
578
+ if (typeof config === 'object' && 'file' in config) {
579
+ return normalizePatternArray(config.file);
580
+ }
581
+ return [];
582
+ }
583
+
584
+ export function setDisabledTraceFiles(config?: DisableTraceFileConfig | DisableTraceFileConfig[] | null) {
585
+ if (config === null || config === undefined) {
586
+ disabledTraceFilePatterns = [];
587
+ return;
588
+ }
589
+ const items = Array.isArray(config) ? config : [config];
590
+ disabledTraceFilePatterns = items.flatMap(entry => flattenTraceFilePatterns(entry)).filter(Boolean);
591
+ }
592
+
593
+ function applyTraceLogPreference(tracer?: TracerApi | null) {
594
+ if (__TRACE_LOG_PREF === null) return;
595
+ try { tracer?.setFunctionLogsEnabled?.(__TRACE_LOG_PREF); } catch {}
596
+ }
597
+
598
+ export function setReproTraceLogsEnabled(enabled: boolean) {
599
+ __TRACE_LOG_PREF = !!enabled;
600
+ applyTraceLogPreference(__TRACER__);
601
+ }
602
+
603
+ export function enableReproTraceLogs() { setReproTraceLogsEnabled(true); }
604
+
605
+ export function disableReproTraceLogs() { setReproTraceLogsEnabled(false); }
606
+
607
+ function summarizeEndpointFromEvents(events: TraceEventRecord[]) {
608
+ let endpointTrace: EndpointTraceInfo | null = null;
609
+ let preferredAppTrace: EndpointTraceInfo | null = null;
610
+ let firstAppTrace: EndpointTraceInfo | null = null;
611
+
612
+ for (const evt of events) {
613
+ if (evt.type !== 'enter') continue;
614
+ if (!isLikelyAppFile(evt.file)) continue;
615
+ const depthOk = evt.depth === undefined || evt.depth <= 6;
616
+ const trace = toEndpointTrace(evt);
617
+
618
+ if (!firstAppTrace && depthOk) {
619
+ firstAppTrace = trace;
620
+ }
621
+
622
+ if (isLikelyNestControllerFile(evt.file)) {
623
+ endpointTrace = trace;
624
+ } else if (depthOk && !preferredAppTrace && !isLikelyNestGuardFile(evt.file)) {
625
+ preferredAppTrace = trace;
626
+ }
627
+ }
628
+
629
+ return { endpointTrace, preferredAppTrace, firstAppTrace };
630
+ }
631
+
632
+ // (function ensureTracerAutoInit() {
633
+ // if (__TRACER_READY) return;
634
+ //
635
+ // try {
636
+ // const tracerPkg: TracerApi = require('../tracer');
637
+ //
638
+ // const cwd = process.cwd().replace(/\\/g, '/');
639
+ // const sdkRoot = __dirname.replace(/\\/g, '/');
640
+ //
641
+ // // include ONLY app code (no node_modules) to avoid interfering with deps
642
+ // const include = [ new RegExp('^' + escapeRx(cwd) + '/(?!node_modules/)') ];
643
+ //
644
+ // // exclude this SDK itself (and any babel internals if present)
645
+ // const exclude = [
646
+ // new RegExp('^' + escapeRx(sdkRoot) + '/'),
647
+ // /node_modules[\\/]@babel[\\/].*/,
648
+ // ];
649
+ //
650
+ // tracerPkg.init?.({
651
+ // instrument: true, // tracer can instrument app code
652
+ // include,
653
+ // exclude, // but never our SDK
654
+ // mode: process.env.TRACE_MODE || 'v8',
655
+ // samplingMs: 10,
656
+ // });
657
+ //
658
+ // tracerPkg.patchHttp?.();
659
+ //
660
+ // __TRACER__ = tracerPkg;
661
+ // __TRACER_READY = true;
662
+ // } catch {
663
+ // __TRACER__ = null; // optional tracer
664
+ // } finally {
665
+ // // Critical: make sure Mongoose core is pristine after tracer init.
666
+ // restoreMongooseIfNeeded();
667
+ // // And again on next tick (if tracer defers some wrapping)
668
+ // setImmediate(restoreMongooseIfNeeded);
669
+ // }
670
+ // })();
671
+ // ===================================================================
672
+
673
+ // ===== Configurable tracer init (explicit, no auto-run) =====
674
+ type TracerInitOpts = {
675
+ instrument?: boolean;
676
+ include?: RegExp[];
677
+ exclude?: RegExp[];
678
+ mode?: string;
679
+ samplingMs?: number;
680
+ };
681
+
682
+ export type ReproTracingInitOptions = TracerInitOpts & {
683
+ /**
684
+ * Optional list of rules or predicates that suppress unwanted function
685
+ * trace events. When omitted, every instrumented function will be
686
+ * recorded. Provide an empty array to reset filters after a previous call.
687
+ */
688
+ disableFunctionTraces?: DisableFunctionTraceConfig[] | null;
689
+ /**
690
+ * Convenience filter that disables every trace event emitted for the
691
+ * provided function types (e.g. `"constructor"`). Accepts a string,
692
+ * regular expression, or array matching the rule syntax above.
693
+ */
694
+ disableFunctionTypes?: TraceRulePattern | null;
695
+ /**
696
+ * Prevents traces emitted from specific files. Accepts glob-like substrings
697
+ * or regular expressions, or the {@link DisableTraceByFilename} helper.
698
+ */
699
+ disableTraceFiles?: DisableTraceFileConfig | DisableTraceFileConfig[] | null;
700
+ /**
701
+ * When `false` (default) Nest interceptors are stripped from traces.
702
+ * Set to `true` to include them if you need to debug interceptor logic.
703
+ */
704
+ traceInterceptors?: boolean;
705
+ /**
706
+ * Enables or silences console logs emitted by the tracer when functions
707
+ * are entered/exited. Equivalent to calling `setReproTraceLogsEnabled`.
708
+ */
709
+ logFunctionCalls?: boolean;
710
+ };
711
+
712
+ function defaultTracerInitOpts(): TracerInitOpts {
713
+ const cwd = process.cwd().replace(/\\/g, '/');
714
+ const sdkRoot = __dirname.replace(/\\/g, '/');
715
+ const escapeRx = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
716
+
717
+ const include = [ new RegExp('^' + escapeRx(cwd) + '/(?!node_modules/)') ];
718
+ // Only skip our own files and Babel internals; repository/service layers stay instrumented.
719
+ const exclude = [
720
+ new RegExp('^' + escapeRx(sdkRoot) + '/'), // never instrument the SDK itself
721
+ /node_modules[\\/]@babel[\\/].*/,
722
+ ];
723
+
724
+ return {
725
+ instrument: true,
726
+ include,
727
+ exclude,
728
+ mode: process.env.TRACE_MODE || 'trace',
729
+ samplingMs: 10,
730
+ };
731
+ }
732
+
733
+ /** Call this from the client app to enable tracing. Safe to call multiple times. */
734
+ export function initReproTracing(opts?: ReproTracingInitOptions) {
735
+ const options = opts ?? {};
736
+
737
+ if (hasOwn(options, 'traceInterceptors')) {
738
+ setInterceptorTracingEnabled(!!options.traceInterceptors);
739
+ }
740
+ if (hasOwn(options, 'disableFunctionTypes')) {
741
+ setDisabledFunctionTypes(options.disableFunctionTypes ?? null);
742
+ }
743
+ if (hasOwn(options, 'disableFunctionTraces')) {
744
+ setDisabledFunctionTraces(options.disableFunctionTraces ?? null);
745
+ }
746
+ if (hasOwn(options, 'disableTraceFiles')) {
747
+ setDisabledTraceFiles(options.disableTraceFiles ?? null);
748
+ }
749
+ if (hasOwn(options, 'logFunctionCalls') && typeof options.logFunctionCalls === 'boolean') {
750
+ setReproTraceLogsEnabled(options.logFunctionCalls);
751
+ }
752
+
753
+ if (__TRACER_READY) {
754
+ applyTraceLogPreference(__TRACER__);
755
+ return __TRACER__;
756
+ }
757
+ try {
758
+ const tracerPkg: TracerApi = require('../tracer');
759
+ __TRACER__ = tracerPkg;
760
+
761
+ applyTraceLogPreference(tracerPkg);
762
+
763
+ const {
764
+ disableFunctionTraces: _disableFunctionTraces,
765
+ disableFunctionTypes: _disableFunctionTypes,
766
+ disableTraceFiles: _disableTraceFiles,
767
+ logFunctionCalls: _logFunctionCalls,
768
+ traceInterceptors: _traceInterceptors,
769
+ ...rest
770
+ } = options;
771
+ const initOpts = { ...defaultTracerInitOpts(), ...(rest as TracerInitOpts) };
772
+ tracerPkg.init?.(initOpts);
773
+ tracerPkg.patchHttp?.();
774
+ applyTraceLogPreference(tracerPkg);
775
+ __TRACER_READY = true;
776
+ patchAllKnownMongooseInstances();
777
+ // Patch again on the next tick to catch mongoose copies that load after init (different versions/copies).
778
+ setImmediate(() => patchAllKnownMongooseInstances());
779
+ } catch {
780
+ __TRACER__ = null; // SDK still works without tracer
781
+ } finally {
782
+ // keep Mongoose prototypes pristine in either case
783
+ restoreMongooseIfNeeded();
784
+ setImmediate(restoreMongooseIfNeeded);
785
+ }
786
+ return __TRACER__;
787
+ }
788
+
789
+ /** Optional helper if users want to check it. */
790
+ export function isReproTracingEnabled() { return __TRACER_READY; }
791
+
792
+ function captureSpanContextFromTracer(source?: any): SpanContext | null {
793
+ try {
794
+ // Prefer a preserved store captured at the call-site for thenables (e.g., Mongoose Query),
795
+ // because the tracer intentionally detaches spans before the query is actually executed.
796
+ const promiseStore = source && source[Symbol.for('__repro_promise_store')];
797
+ if (promiseStore) {
798
+ const stack = Array.isArray(promiseStore.__repro_span_stack) ? promiseStore.__repro_span_stack : [];
799
+ const top = stack.length ? stack[stack.length - 1] : null;
800
+ const spanId = top && top.id != null ? top.id : null;
801
+ const parentSpanId = top && top.parentId != null
802
+ ? top.parentId
803
+ : (stack.length >= 2 ? (stack[stack.length - 2]?.id ?? null) : null);
804
+ const depth = top && top.depth != null
805
+ ? top.depth
806
+ : (typeof promiseStore.depth === 'number'
807
+ ? promiseStore.depth
808
+ : (stack.length ? stack.length : null));
809
+ const traceId = promiseStore.traceId ?? null;
810
+ if (traceId || spanId !== null || parentSpanId !== null) {
811
+ return { traceId, spanId, parentSpanId, depth: depth == null ? null : depth };
812
+ }
813
+ }
814
+
815
+ const fromSource = source && source.__repro_span_context;
816
+ if (fromSource) {
817
+ return {
818
+ traceId: fromSource.traceId ?? null,
819
+ spanId: fromSource.spanId ?? null,
820
+ parentSpanId: fromSource.parentSpanId ?? null,
821
+ depth: fromSource.depth ?? null,
822
+ };
823
+ }
824
+
825
+ const ctx = __TRACER__?.getCurrentSpanContext?.();
826
+ if (ctx) {
827
+ const span: SpanContext = {
828
+ traceId: ctx.traceId ?? __TRACER__?.getCurrentTraceId?.() ?? null,
829
+ spanId: ctx.spanId ?? null,
830
+ parentSpanId: ctx.parentSpanId ?? null,
831
+ depth: ctx.depth ?? null,
832
+ };
833
+ if (span.traceId || span.spanId !== null || span.parentSpanId !== null) {
834
+ return span;
835
+ }
836
+ } else if (__TRACER__?.getCurrentTraceId) {
837
+ const traceId = __TRACER__?.getCurrentTraceId?.() ?? null;
838
+ if (traceId) {
839
+ return { traceId, spanId: null, parentSpanId: null, depth: null };
840
+ }
841
+ }
842
+ } catch {}
843
+ return null;
844
+ }
845
+
846
+ function isExcludedSpanId(spanId: string | number | null | undefined): boolean {
847
+ if (spanId === null || spanId === undefined) return false;
848
+ try {
849
+ const excluded = (getCtx() as Ctx).excludedSpanIds;
850
+ if (!excluded || excluded.size === 0) return false;
851
+ return excluded.has(String(spanId));
852
+ } catch {
853
+ return false;
854
+ }
855
+ }
856
+
857
+ function shouldCaptureDbSpan(span: SpanContext | null | undefined): span is SpanContext {
858
+ if (!span || span.spanId === null || span.spanId === undefined) return false;
859
+ if (isExcludedSpanId(span.spanId)) return false;
860
+ return true;
861
+ }
862
+
863
+ function attachSpanContext<T extends Record<string, any>>(target: T, span?: SpanContext | null): T {
864
+ if (!target) return target;
865
+ const ctx = span ?? captureSpanContextFromTracer();
866
+ if (ctx) {
867
+ try { (target as any).spanContext = ctx; } catch {}
868
+ }
869
+ return target;
870
+ }
871
+
872
+ type Ctx = { sid?: string; aid?: string; clockSkewMs?: number; excludedSpanIds?: Set<string> };
873
+ const als = new AsyncLocalStorage<Ctx>();
874
+ const getCtx = () => als.getStore() || {};
875
+
876
+ function currentClockSkewMs(): number {
877
+ const store = als.getStore();
878
+ const skew = store?.clockSkewMs;
879
+ return Number.isFinite(skew) ? Number(skew) : 0;
880
+ }
881
+
882
+ function alignTimestamp(ms: number): number {
883
+ if (!Number.isFinite(ms)) return ms;
884
+ const skew = currentClockSkewMs();
885
+ return Number.isFinite(skew) ? ms + skew : ms;
886
+ }
887
+
888
+ function alignedNow(): number {
889
+ return alignTimestamp(Date.now());
890
+ }
891
+
892
+ let __REQUEST_SEQ = 0;
893
+ function nextRequestId(base: number | string): string {
894
+ const seq = (++__REQUEST_SEQ).toString(36);
895
+ const rand = Math.floor(Math.random() * 1e6).toString(36);
896
+ return `${base}-${seq}${rand}`;
897
+ }
898
+
899
+ // Track in-flight requests per session so we can delay flushing until a session is drained.
900
+ const sessionInFlight = new Map<string, number>();
901
+ const sessionWaiters = new Map<string, Array<() => void>>();
902
+
903
+ function beginSessionRequest(sessionId: string) {
904
+ if (!sessionId) return;
905
+ sessionInFlight.set(sessionId, (sessionInFlight.get(sessionId) || 0) + 1);
906
+ }
907
+
908
+ function endSessionRequest(sessionId: string) {
909
+ if (!sessionId) return;
910
+ const next = (sessionInFlight.get(sessionId) || 0) - 1;
911
+ if (next > 0) {
912
+ sessionInFlight.set(sessionId, next);
913
+ return;
914
+ }
915
+ sessionInFlight.delete(sessionId);
916
+ const waiters = sessionWaiters.get(sessionId);
917
+ if (waiters && waiters.length) {
918
+ sessionWaiters.delete(sessionId);
919
+ waiters.forEach(fn => { try { fn(); } catch {} });
920
+ }
921
+ }
922
+
923
+ function waitForSessionDrain(sessionId: string): Promise<void> {
924
+ if (!sessionId) return Promise.resolve();
925
+ const remaining = sessionInFlight.get(sessionId) || 0;
926
+ if (remaining <= 0) return Promise.resolve();
927
+
928
+ return new Promise(resolve => {
929
+ const list = sessionWaiters.get(sessionId) || [];
930
+ list.push(resolve);
931
+ sessionWaiters.set(sessionId, list);
932
+ if (SESSION_DRAIN_TIMEOUT_MS > 0 && Number.isFinite(SESSION_DRAIN_TIMEOUT_MS)) {
933
+ setTimeout(resolve, SESSION_DRAIN_TIMEOUT_MS);
934
+ }
935
+ });
936
+ }
937
+
938
+ function balanceTraceEvents(events: TraceEventRecord[]): TraceEventRecord[] {
939
+ // No balancing / synthetic exits — preserve raw event stream.
940
+ return Array.isArray(events) ? events : [];
941
+ }
942
+
943
+ function sortTraceEventsChronologically(events: TraceEventRecord[]): TraceEventRecord[] {
944
+ return events
945
+ .map((ev, idx) => ({ ev, idx }))
946
+ .sort((a, b) => {
947
+ const ta = typeof a.ev.t === 'number' ? a.ev.t : Number.POSITIVE_INFINITY;
948
+ const tb = typeof b.ev.t === 'number' ? b.ev.t : Number.POSITIVE_INFINITY;
949
+ if (ta !== tb) return ta - tb;
950
+ return a.idx - b.idx; // stable fallback
951
+ })
952
+ .map(w => w.ev);
953
+ }
954
+
955
+ function computeDepthsFromParentsChronologically(events: TraceEventRecord[]): TraceEventRecord[] {
956
+ const depthBySpan = new Map<string, number>();
957
+
958
+ const normId = (v: any) => (v === null || v === undefined ? null : String(v));
959
+
960
+ return events.map(ev => {
961
+ const out = { ...ev };
962
+ const sid = normId(ev.spanId);
963
+ const pid = normId(ev.parentSpanId);
964
+
965
+ if (ev.type === 'enter') {
966
+ const parentDepth = pid ? depthBySpan.get(pid) ?? 0 : 0;
967
+ const depth = Math.max(1, parentDepth + 1);
968
+ out.depth = depth;
969
+ if (sid) depthBySpan.set(sid, depth);
970
+ return out;
971
+ }
972
+
973
+ if (ev.type === 'exit') {
974
+ if (sid && depthBySpan.has(sid)) {
975
+ out.depth = depthBySpan.get(sid);
976
+ } else if (pid && depthBySpan.has(pid)) {
977
+ out.depth = Math.max(1, (depthBySpan.get(pid) ?? 0) + 1);
978
+ } else if (typeof ev.depth === 'number') {
979
+ out.depth = ev.depth;
980
+ } else {
981
+ out.depth = 1;
982
+ }
983
+ return out;
984
+ }
985
+
986
+ return out;
987
+ });
988
+ }
989
+
990
+ function reorderTraceEvents(events: TraceEventRecord[]): TraceEventRecord[] {
991
+ if (!Array.isArray(events) || !events.length) return events;
992
+
993
+ type SpanNode = {
994
+ id: string;
995
+ parentId: string | null;
996
+ enter?: TraceEventRecord;
997
+ exit?: TraceEventRecord;
998
+ children: SpanNode[];
999
+ order: number;
1000
+ };
1001
+
1002
+ const nodes = new Map<string, SpanNode>();
1003
+ const roots: Array<SpanNode | { order: number; ev: TraceEventRecord }> = [];
1004
+
1005
+ const normalizeId = (v: any) => (v === null || v === undefined ? null : String(v));
1006
+
1007
+ const ensureNode = (id: string): SpanNode => {
1008
+ let n = nodes.get(id);
1009
+ if (!n) {
1010
+ n = { id, parentId: null, children: [], order: Number.POSITIVE_INFINITY };
1011
+ nodes.set(id, n);
1012
+ }
1013
+ return n;
1014
+ };
1015
+
1016
+ events.forEach((ev, idx) => {
1017
+ const spanId = normalizeId(ev.spanId);
1018
+ const parentId = normalizeId(ev.parentSpanId);
1019
+ if (!spanId) {
1020
+ roots.push({ order: idx, ev });
1021
+ return;
1022
+ }
1023
+ const node = ensureNode(spanId);
1024
+ node.order = Math.min(node.order, idx);
1025
+ node.parentId = parentId;
1026
+ if (ev.type === 'enter') node.enter = node.enter ?? ev;
1027
+ if (ev.type === 'exit') node.exit = ev;
1028
+ });
1029
+
1030
+ nodes.forEach(node => {
1031
+ if (node.parentId && nodes.has(node.parentId)) {
1032
+ const parent = nodes.get(node.parentId)!;
1033
+ parent.children.push(node);
1034
+ } else {
1035
+ roots.push(node);
1036
+ }
1037
+ });
1038
+
1039
+ const sortChildren = (node: SpanNode) => {
1040
+ node.children.sort((a, b) => a.order - b.order);
1041
+ node.children.forEach(sortChildren);
1042
+ };
1043
+ nodes.forEach(sortChildren);
1044
+
1045
+ roots.sort((a, b) => a.order - b.order);
1046
+
1047
+ const out: TraceEventRecord[] = [];
1048
+ const emitNode = (node: SpanNode, depth: number) => {
1049
+ if (node.enter) {
1050
+ node.enter.depth = depth;
1051
+ out.push(node.enter);
1052
+ }
1053
+ node.children.forEach(child => emitNode(child, depth + 1));
1054
+ if (node.exit) {
1055
+ node.exit.depth = depth;
1056
+ out.push(node.exit);
1057
+ }
1058
+ };
1059
+
1060
+ roots.forEach(entry => {
1061
+ if ('ev' in entry) {
1062
+ out.push(entry.ev);
1063
+ } else {
1064
+ emitNode(entry as SpanNode, 1);
1065
+ }
1066
+ });
1067
+
1068
+ return out;
1069
+ }
1070
+
1071
+ function getCollectionNameFromDoc(doc: any): string | undefined {
1072
+ const direct =
1073
+ doc?.$__?.collection?.collectionName ||
1074
+ (doc?.$collection as any)?.collectionName ||
1075
+ doc?.collection?.collectionName ||
1076
+ (doc?.collection as any)?.name ||
1077
+ (doc?.constructor as any)?.collection?.collectionName;
1078
+
1079
+ if (direct) return direct;
1080
+
1081
+ if (doc?.$isSubdocument && typeof doc.ownerDocument === 'function') {
1082
+ const parent = doc.ownerDocument();
1083
+ return (
1084
+ parent?.$__?.collection?.collectionName ||
1085
+ (parent?.$collection as any)?.collectionName ||
1086
+ parent?.collection?.collectionName ||
1087
+ (parent?.collection as any)?.name ||
1088
+ (parent?.constructor as any)?.collection?.collectionName
1089
+ );
1090
+ }
1091
+
1092
+ const ctor = doc?.constructor as any;
1093
+ if (ctor?.base && ctor?.base?.collection?.collectionName) {
1094
+ return ctor.base.collection.collectionName;
1095
+ }
1096
+
1097
+ return undefined;
1098
+ }
1099
+
1100
+ function getCollectionNameFromQuery(q: any): string | undefined {
1101
+ return q?.model?.collection?.collectionName || (q?.model?.collection as any)?.name;
1102
+ }
1103
+
1104
+ function resolveCollectionOrWarn(source: any, type: 'doc' | 'query'): string {
1105
+ const name =
1106
+ (type === 'doc'
1107
+ ? getCollectionNameFromDoc(source)
1108
+ : getCollectionNameFromQuery(source)) || undefined;
1109
+
1110
+ if (!name) {
1111
+ try {
1112
+ const modelName =
1113
+ type === 'doc'
1114
+ ? (source?.constructor as any)?.modelName ||
1115
+ (source?.ownerDocument?.() as any)?.constructor?.modelName
1116
+ : source?.model?.modelName;
1117
+ // eslint-disable-next-line no-console
1118
+ console.warn('[repro] could not resolve collection name', { type, modelName });
1119
+ } catch {}
1120
+ return 'unknown';
1121
+ }
1122
+ return name;
1123
+ }
1124
+
1125
+ async function post(
1126
+ cfg: { appId: string; appSecret: string; appName?: string; apiBase?: string },
1127
+ sessionId: string,
1128
+ body: any,
1129
+ ) {
1130
+ try {
1131
+ const envBase = typeof process !== 'undefined' ? (process as any)?.env?.REPRO_API_BASE : undefined;
1132
+ const legacyBase = (cfg as any)?.apiBase;
1133
+ const apiBase = String(envBase || legacyBase || 'https://oozy-loreta-gully.ngrok-free.dev').replace(/\/+$/, '');
1134
+ await fetch(`${apiBase}/v1/sessions/${sessionId}/backend`, {
1135
+ method: 'POST',
1136
+ headers: {
1137
+ 'Content-Type': 'application/json',
1138
+ 'X-App-Id': cfg.appId,
1139
+ 'X-App-Secret': cfg.appSecret,
1140
+ ...(cfg.appName ? { 'X-App-Name': cfg.appName } : {}),
1141
+ },
1142
+ body: JSON.stringify(body),
1143
+ });
1144
+ } catch { /* swallow in SDK */ }
1145
+ }
1146
+
1147
+ function readHeaderNumber(value: string | string[] | undefined): number | null {
1148
+ const raw = Array.isArray(value) ? value[0] : value;
1149
+ if (typeof raw !== 'string') return null;
1150
+ const trimmed = raw.trim();
1151
+ if (!trimmed) return null;
1152
+ const parsed = Number(trimmed);
1153
+ return Number.isFinite(parsed) ? parsed : null;
1154
+ }
1155
+
1156
+ // -------- helpers for response capture & grouping --------
1157
+ function normalizeRouteKey(method: string, rawPath: string) {
1158
+ const base = (rawPath || '/').split('?')[0] || '/';
1159
+ return `${String(method || 'GET').toUpperCase()} ${base}`;
1160
+ }
1161
+
1162
+ function normalizeFilePath(file?: string | null): string {
1163
+ return file ? String(file).replace(/\\/g, '/').toLowerCase() : '';
1164
+ }
1165
+
1166
+ function isLikelyAppFile(file?: string | null): boolean {
1167
+ if (!file) return false;
1168
+ const normalized = String(file).replace(/\\/g, '/');
1169
+ if (!normalized) return false;
1170
+ return !normalized.includes('/node_modules/');
1171
+ }
1172
+
1173
+ function isLikelyNestControllerFile(file?: string | null): boolean {
1174
+ const normalized = normalizeFilePath(file);
1175
+ if (!normalized) return false;
1176
+ if (!isLikelyAppFile(file)) return false;
1177
+ return (
1178
+ normalized.includes('.controller.') ||
1179
+ normalized.includes('/controllers/') ||
1180
+ normalized.includes('.resolver.') ||
1181
+ normalized.includes('/resolvers/')
1182
+ );
1183
+ }
1184
+
1185
+ function isLikelyNestGuardFile(file?: string | null): boolean {
1186
+ const normalized = normalizeFilePath(file);
1187
+ if (!normalized) return false;
1188
+ return normalized.includes('.guard.') || normalized.includes('/guards/');
1189
+ }
1190
+
1191
+ function toEndpointTrace(evt: {
1192
+ fn?: string;
1193
+ file?: string;
1194
+ line?: number | null;
1195
+ functionType?: string | null;
1196
+ }): EndpointTraceInfo {
1197
+ return {
1198
+ fn: evt.fn ?? null,
1199
+ file: evt.file ?? null,
1200
+ line: evt.line ?? null,
1201
+ functionType: evt.functionType ?? null,
1202
+ };
1203
+ }
1204
+
1205
+ function coerceBodyToStorable(body: any, contentType?: string | number | string[]) {
1206
+ if (body && typeof body === 'object' && !Buffer.isBuffer(body)) return body;
1207
+
1208
+ const ct = Array.isArray(contentType) ? String(contentType[0]) : String(contentType || '');
1209
+ const isLikelyJson = ct.toLowerCase().includes('application/json');
1210
+
1211
+ try {
1212
+ if (Buffer.isBuffer(body)) {
1213
+ const s = body.toString('utf8');
1214
+ return isLikelyJson ? JSON.parse(s) : s;
1215
+ }
1216
+ if (typeof body === 'string') {
1217
+ return isLikelyJson ? JSON.parse(body) : body;
1218
+ }
1219
+ } catch {
1220
+ if (Buffer.isBuffer(body)) return body.toString('utf8');
1221
+ if (typeof body === 'string') return body;
1222
+ }
1223
+ return body;
1224
+ }
1225
+
1226
+ const TRACE_VALUE_MAX_DEPTH = 3;
1227
+ const TRACE_VALUE_MAX_KEYS = 20;
1228
+ const TRACE_VALUE_MAX_ITEMS = 20;
1229
+ const TRACE_VALUE_MAX_STRING = 2000;
1230
+ const TRACE_BATCH_SIZE = 100;
1231
+ const TRACE_FLUSH_DELAY_MS = 20;
1232
+ // Choose how to order trace events in payloads.
1233
+ // - "chronological" (default): preserve event arrival order (no reshuffle).
1234
+ // - "tree": rebuild a parent/child tree from spanIds for depth visualizations (can shuffle concurrent spans).
1235
+ const TRACE_ORDER_MODE = (() => {
1236
+ const mode = String(process.env.TRACE_ORDER_MODE || '').toLowerCase().trim();
1237
+ return mode === 'tree' ? 'tree' : 'chronological';
1238
+ })();
1239
+ // Extra grace period after res.finish to catch late fire-and-forget work before unsubscribing.
1240
+ const TRACE_LINGER_AFTER_FINISH_MS = (() => {
1241
+ const env = Number(process.env.TRACE_LINGER_AFTER_FINISH_MS);
1242
+ if (Number.isFinite(env) && env >= 0) return env;
1243
+ return 1000; // default 1s to catch slow async callbacks (e.g., email sends)
1244
+ })();
1245
+ const TRACE_IDLE_FLUSH_MS = (() => {
1246
+ const env = Number(process.env.TRACE_IDLE_FLUSH_MS);
1247
+ if (Number.isFinite(env) && env > 0) return env;
1248
+ // Keep the listener alive until no new events arrive for this many ms after finish.
1249
+ return 2000;
1250
+ })();
1251
+ const SESSION_DRAIN_TIMEOUT_MS = (() => {
1252
+ const env = Number(process.env.SESSION_DRAIN_TIMEOUT_MS);
1253
+ if (Number.isFinite(env) && env >= 0) return env;
1254
+ // Bound wait for draining sessions to avoid lost flushes when a request hangs.
1255
+ return 10000;
1256
+ })();
1257
+
1258
+ function isThenable(value: any): value is PromiseLike<any> {
1259
+ return value != null && typeof value === 'object' && typeof (value as any).then === 'function';
1260
+ }
1261
+
1262
+ function coerceMongoId(value: any): string | null {
1263
+ if (!value) return null;
1264
+ const name = value?.constructor?.name;
1265
+ if (name === 'ObjectId' || name === 'ObjectID' || value?._bsontype === 'ObjectID') {
1266
+ try {
1267
+ if (typeof (value as any).toHexString === 'function') return (value as any).toHexString();
1268
+ if (typeof (value as any).toString === 'function') return (value as any).toString();
1269
+ } catch {}
1270
+ return '[mongo-id]';
1271
+ }
1272
+ return null;
1273
+ }
1274
+
1275
+ function isMongoSessionLike(value: any): boolean {
1276
+ const ctor = value?.constructor?.name?.toLowerCase?.() || '';
1277
+ return (
1278
+ !!ctor &&
1279
+ ctor.includes('session') &&
1280
+ (typeof (value as any).endSession === 'function' || typeof (value as any).inTransaction === 'function')
1281
+ );
1282
+ }
1283
+
1284
+ function describeMongoPlaceholder(value: any): string | null {
1285
+ const ctor = value?.constructor?.name;
1286
+ if (!ctor) return null;
1287
+ const lower = ctor.toLowerCase();
1288
+
1289
+ if (isMongoSessionLike(value)) return 'mongo-session';
1290
+ if (lower.includes('cursor')) return 'mongo-cursor';
1291
+ if (lower.includes('topology')) return 'mongo-topology';
1292
+ if (lower.includes('connection')) return 'mongo-connection';
1293
+ if (lower.includes('collection')) {
1294
+ const name = (value as any)?.collectionName || (value as any)?.name;
1295
+ return name ? `mongo-collection(${name})` : 'mongo-collection';
1296
+ }
1297
+ if (lower.includes('db') && typeof (value as any).command === 'function') return 'mongo-db';
1298
+ return null;
1299
+ }
1300
+
1301
+ function isMongooseDocumentLike(value: any): boolean {
1302
+ return !!value && typeof value === 'object' && typeof (value as any).toObject === 'function' && !!(value as any).$__;
1303
+ }
1304
+
1305
+ function toPlainMongooseDoc(value: any): any | null {
1306
+ try {
1307
+ const plain = (value as any).toObject?.({ depopulate: true, virtuals: false, minimize: false, getters: false });
1308
+ if (plain && plain !== value) return plain;
1309
+ } catch {}
1310
+ try {
1311
+ const json = (value as any).toJSON?.();
1312
+ if (json && json !== value) return json;
1313
+ } catch {}
1314
+ return null;
1315
+ }
1316
+
1317
+ function isMongooseQueryLike(value: any): boolean {
1318
+ return !!value && typeof value === 'object' && typeof (value as any).exec === 'function' && ((value as any).model || (value as any).op);
1319
+ }
1320
+
1321
+ function summarizeMongooseQueryValue(value: any, depth: number, seen: WeakSet<object>) {
1322
+ try {
1323
+ const model = (value as any).model?.modelName || (value as any)._model?.modelName || undefined;
1324
+ const op = (value as any).op || (value as any).operation || (value as any).options?.op || undefined;
1325
+ return {
1326
+ __type: 'MongooseQuery',
1327
+ model,
1328
+ op,
1329
+ filter: sanitizeTraceValue((value as any).getFilter?.() ?? (value as any)._conditions, depth + 1, seen),
1330
+ update: sanitizeTraceValue((value as any).getUpdate?.() ?? (value as any)._update, depth + 1, seen),
1331
+ options: sanitizeTraceValue((value as any).getOptions?.() ?? (value as any).options, depth + 1, seen),
1332
+ };
1333
+ } catch {
1334
+ return 'mongo-query';
1335
+ }
1336
+ }
1337
+
1338
+ function safeStringifyUnknown(value: any): string | undefined {
1339
+ try {
1340
+ const str = String(value);
1341
+ if (str === '[object Object]') return '[unserializable]';
1342
+ return str;
1343
+ } catch {
1344
+ return undefined;
1345
+ }
1346
+ }
1347
+
1348
+ function chunkArray<T>(arr: T[], size: number): T[][] {
1349
+ if (!Array.isArray(arr) || arr.length === 0) return [];
1350
+ if (!size || size <= 0) return [arr.slice()];
1351
+
1352
+ const batches: T[][] = [];
1353
+ for (let i = 0; i < arr.length; i += size) {
1354
+ batches.push(arr.slice(i, i + size));
1355
+ }
1356
+ return batches;
1357
+ }
1358
+
1359
+ function sanitizeTraceValue(value: any, depth = 0, seen: WeakSet<object> = new WeakSet()): any {
1360
+ if (value === null || value === undefined) return value;
1361
+ const type = typeof value;
1362
+
1363
+ if (type === 'number' || type === 'boolean') return value;
1364
+ if (type === 'string') {
1365
+ if (value.length <= TRACE_VALUE_MAX_STRING) return value;
1366
+ return `${value.slice(0, TRACE_VALUE_MAX_STRING)}…(${value.length - TRACE_VALUE_MAX_STRING} more chars)`;
1367
+ }
1368
+ if (type === 'bigint') return value.toString();
1369
+ if (type === 'symbol') return value.toString();
1370
+ if (type === 'function') return `[Function${value.name ? ` ${value.name}` : ''}]`;
1371
+
1372
+ const mongoId = coerceMongoId(value);
1373
+ if (mongoId !== null) return mongoId;
1374
+
1375
+ if (isMongooseQueryLike(value)) {
1376
+ const captured = (value as any).__repro_result;
1377
+ if (captured !== undefined) {
1378
+ return sanitizeTraceValue(captured, depth + 1, seen);
1379
+ }
1380
+ return summarizeMongooseQueryValue(value, depth, seen);
1381
+ }
1382
+
1383
+ const mongoPlaceholder = describeMongoPlaceholder(value);
1384
+ if (mongoPlaceholder) return mongoPlaceholder;
1385
+
1386
+ if (isThenable(value)) {
1387
+ return { __type: 'Promise', state: 'pending' };
1388
+ }
1389
+
1390
+ if (isMongooseDocumentLike(value)) {
1391
+ const plain = toPlainMongooseDoc(value);
1392
+ if (plain && plain !== value) {
1393
+ return sanitizeTraceValue(plain, depth, seen);
1394
+ }
1395
+ }
1396
+
1397
+ if (Buffer.isBuffer(value)) {
1398
+ return {
1399
+ __type: 'Buffer',
1400
+ length: value.length,
1401
+ preview: value.length ? value.slice(0, 32).toString('hex') : '',
1402
+ };
1403
+ }
1404
+
1405
+ if (value instanceof Date) return value.toISOString();
1406
+ if (value instanceof RegExp) return value.toString();
1407
+ if (value instanceof Error) {
1408
+ return {
1409
+ name: value.name,
1410
+ message: value.message,
1411
+ stack: value.stack,
1412
+ };
1413
+ }
1414
+
1415
+ if (type !== 'object') return String(value);
1416
+
1417
+ if (!Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set)) {
1418
+ const dehydrated = dehydrateComplexValue(value);
1419
+ if (dehydrated !== value) {
1420
+ return sanitizeTraceValue(dehydrated, depth, seen);
1421
+ }
1422
+ }
1423
+
1424
+ if (seen.has(value)) return '[Circular]';
1425
+ seen.add(value);
1426
+
1427
+ if (depth >= TRACE_VALUE_MAX_DEPTH) {
1428
+ const shallow = safeJson(value);
1429
+ if (shallow !== undefined) {
1430
+ return shallow;
1431
+ }
1432
+ const ctor = value?.constructor?.name;
1433
+ return ctor && ctor !== 'Object'
1434
+ ? { __class: ctor, __truncated: `depth>${TRACE_VALUE_MAX_DEPTH}` }
1435
+ : { __truncated: `depth>${TRACE_VALUE_MAX_DEPTH}` };
1436
+ }
1437
+
1438
+ if (Array.isArray(value)) {
1439
+ const out = value.slice(0, TRACE_VALUE_MAX_ITEMS)
1440
+ .map(item => sanitizeTraceValue(item, depth + 1, seen));
1441
+ if (value.length > TRACE_VALUE_MAX_ITEMS) {
1442
+ out.push(`…(${value.length - TRACE_VALUE_MAX_ITEMS} more items)`);
1443
+ }
1444
+ return out;
1445
+ }
1446
+
1447
+ if (value instanceof Map) {
1448
+ const entries: Array<[any, any]> = [];
1449
+ for (const [k, v] of value) {
1450
+ if (entries.length >= TRACE_VALUE_MAX_ITEMS) break;
1451
+ entries.push([
1452
+ sanitizeTraceValue(k, depth + 1, seen),
1453
+ sanitizeTraceValue(v, depth + 1, seen)
1454
+ ]);
1455
+ }
1456
+ if (value.size > TRACE_VALUE_MAX_ITEMS) {
1457
+ entries.push([`…(${value.size - TRACE_VALUE_MAX_ITEMS} more entries)`, null]);
1458
+ }
1459
+ return { __type: 'Map', entries };
1460
+ }
1461
+
1462
+ if (value instanceof Set) {
1463
+ const arr: any[] = [];
1464
+ for (const item of value) {
1465
+ if (arr.length >= TRACE_VALUE_MAX_ITEMS) break;
1466
+ arr.push(sanitizeTraceValue(item, depth + 1, seen));
1467
+ }
1468
+ if (value.size > TRACE_VALUE_MAX_ITEMS) {
1469
+ arr.push(`…(${value.size - TRACE_VALUE_MAX_ITEMS} more items)`);
1470
+ }
1471
+ return { __type: 'Set', values: arr };
1472
+ }
1473
+
1474
+ const ctor = value?.constructor?.name;
1475
+ const result: Record<string, any> = {};
1476
+ const keys = Object.keys(value);
1477
+ for (const key of keys.slice(0, TRACE_VALUE_MAX_KEYS)) {
1478
+ try {
1479
+ result[key] = sanitizeTraceValue((value as any)[key], depth + 1, seen);
1480
+ } catch (err) {
1481
+ result[key] = `[Cannot serialize: ${(err as Error)?.message || 'unknown error'}]`;
1482
+ }
1483
+ }
1484
+ if (keys.length > TRACE_VALUE_MAX_KEYS) {
1485
+ result.__truncatedKeys = keys.length - TRACE_VALUE_MAX_KEYS;
1486
+ }
1487
+ if (ctor && ctor !== 'Object') {
1488
+ result.__class = ctor;
1489
+ }
1490
+ return result;
1491
+ }
1492
+
1493
+ function sanitizeTraceArgs(values: any): any {
1494
+ if (!Array.isArray(values)) return values;
1495
+ return values.map(v => sanitizeTraceValue(v));
1496
+ }
1497
+
1498
+ function sanitizeRequestSnapshot(value: any) {
1499
+ if (value === undefined) return undefined;
1500
+ try {
1501
+ return sanitizeTraceValue(value);
1502
+ } catch {
1503
+ if (value === null) return null;
1504
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
1505
+ return value;
1506
+ }
1507
+ try { return JSON.parse(JSON.stringify(value)); } catch {}
1508
+ return safeStringifyUnknown(value);
1509
+ }
1510
+ }
1511
+
1512
+ const DEFAULT_SENSITIVE_HEADERS: Array<string | RegExp> = [
1513
+ 'authorization',
1514
+ 'proxy-authorization',
1515
+ 'authentication',
1516
+ 'auth',
1517
+ 'x-api-key',
1518
+ 'api-key',
1519
+ 'apikey',
1520
+ 'x-api-token',
1521
+ 'x-access-token',
1522
+ 'x-auth-token',
1523
+ 'x-id-token',
1524
+ 'x-refresh-token',
1525
+ 'id-token',
1526
+ 'refresh-token',
1527
+ 'cookie',
1528
+ 'set-cookie',
1529
+ ];
1530
+
1531
+ const DEFAULT_MASK_REPLACEMENT = '[REDACTED]';
1532
+
1533
+ function normalizeHeaderRules(rules?: HeaderRule | HeaderRule[] | null): HeaderRule[] {
1534
+ return normalizePatternArray<HeaderRule>(rules || []);
1535
+ }
1536
+
1537
+ function matchesHeaderRule(name: string, rules: HeaderRule[]): boolean {
1538
+ if (!rules || !rules.length) return false;
1539
+ const lower = name.toLowerCase();
1540
+ return rules.some(rule => {
1541
+ if (rule instanceof RegExp) {
1542
+ try { return rule.test(name); } catch { return false; }
1543
+ }
1544
+ return lower === String(rule).toLowerCase();
1545
+ });
1546
+ }
1547
+
1548
+ function normalizeHeaderCaptureConfig(raw?: boolean | HeaderCaptureOptions): NormalizedHeaderCapture {
1549
+ if (raw === false) {
1550
+ return { enabled: false, allowSensitive: false, mask: [], unmask: [] };
1551
+ }
1552
+ const opts: HeaderCaptureOptions = raw && raw !== true ? raw : {};
1553
+ return {
1554
+ enabled: true,
1555
+ allowSensitive: opts.allowSensitiveHeaders === true,
1556
+ mask: [...normalizeHeaderRules(opts.maskHeaders), ...normalizeHeaderRules(opts.dropHeaders)],
1557
+ unmask: [...normalizeHeaderRules(opts.unmaskHeaders), ...normalizeHeaderRules(opts.keepHeaders)],
1558
+ };
1559
+ }
1560
+
1561
+ function sanitizeHeaderValue(value: any): any {
1562
+ if (value === undefined) return undefined;
1563
+ if (Array.isArray(value)) {
1564
+ const arr = value.map(v => sanitizeHeaderValue(v)).filter(v => v !== undefined);
1565
+ return arr.length ? arr : undefined;
1566
+ }
1567
+ if (value === null) return null;
1568
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
1569
+ const safe = safeJson(value);
1570
+ if (safe !== undefined) return safe;
1571
+ return safeStringifyUnknown(value);
1572
+ }
1573
+
1574
+ function sanitizeHeaders(headers: any, rawCfg?: boolean | HeaderCaptureOptions) {
1575
+ const cfg = normalizeHeaderCaptureConfig(rawCfg);
1576
+ if (!cfg.enabled) return {};
1577
+
1578
+ const out: Record<string, any> = {};
1579
+
1580
+ for (const [rawKey, rawVal] of Object.entries(headers || {})) {
1581
+ const key = String(rawKey || '').toLowerCase();
1582
+ if (!key) continue;
1583
+ const sanitizedValue = sanitizeHeaderValue(rawVal);
1584
+ if (sanitizedValue !== undefined) {
1585
+ out[key] = sanitizedValue;
1586
+ }
1587
+ }
1588
+
1589
+ const maskList = cfg.allowSensitive ? cfg.mask : [...DEFAULT_SENSITIVE_HEADERS, ...cfg.mask];
1590
+ Object.keys(out).forEach((key) => {
1591
+ if (!matchesHeaderRule(key, maskList)) return;
1592
+ if (matchesHeaderRule(key, cfg.unmask)) return;
1593
+ const current = out[key];
1594
+ if (Array.isArray(current)) {
1595
+ out[key] = current.map(() => DEFAULT_MASK_REPLACEMENT);
1596
+ return;
1597
+ }
1598
+ out[key] = DEFAULT_MASK_REPLACEMENT;
1599
+ });
1600
+
1601
+ return out;
1602
+ }
1603
+
1604
+ // ===================================================================
1605
+ // Masking (request/response payloads + trace args/returns/errors)
1606
+ // ===================================================================
1607
+ type NormalizedMaskRule = {
1608
+ when?: ReproMaskWhen;
1609
+ targets: ReproMaskTarget[];
1610
+ paths: string[][];
1611
+ keys?: TraceRulePattern;
1612
+ replacement: any;
1613
+ };
1614
+
1615
+ type NormalizedMaskingConfig = {
1616
+ replacement: any;
1617
+ rules: NormalizedMaskRule[];
1618
+ };
1619
+
1620
+ type MaskRequestContext = {
1621
+ method: string;
1622
+ path: string;
1623
+ key: string;
1624
+ };
1625
+
1626
+ function normalizeMaskTargets(target: ReproMaskTarget | ReproMaskTarget[]): ReproMaskTarget[] {
1627
+ const out = Array.isArray(target) ? target : [target];
1628
+ return out.filter((t): t is ReproMaskTarget => typeof t === 'string' && t.length > 0);
1629
+ }
1630
+
1631
+ function parseMaskPath(raw: string): string[] {
1632
+ if (!raw) return [];
1633
+ let path = String(raw).trim();
1634
+ if (!path) return [];
1635
+ if (path.startsWith('$.')) path = path.slice(2);
1636
+ if (path.startsWith('.')) path = path.slice(1);
1637
+ path = path.replace(/\[(\d+|\*)\]/g, '.$1');
1638
+ return path.split('.').map(s => s.trim()).filter(Boolean);
1639
+ }
1640
+
1641
+ function normalizeMaskPaths(paths?: string | string[]): string[][] {
1642
+ if (!paths) return [];
1643
+ const list = Array.isArray(paths) ? paths : [paths];
1644
+ return list
1645
+ .map(p => parseMaskPath(p))
1646
+ .filter(parts => parts.length > 0);
1647
+ }
1648
+
1649
+ function normalizeMaskingConfig(raw?: ReproMaskingConfig): NormalizedMaskingConfig | null {
1650
+ const rules = raw?.rules;
1651
+ if (!Array.isArray(rules) || rules.length === 0) return null;
1652
+ const replacement = raw?.replacement ?? DEFAULT_MASK_REPLACEMENT;
1653
+ const normalized: NormalizedMaskRule[] = [];
1654
+ for (const rule of rules) {
1655
+ if (!rule) continue;
1656
+ const targets = normalizeMaskTargets(rule.target);
1657
+ if (!targets.length) continue;
1658
+ const paths = normalizeMaskPaths(rule.paths);
1659
+ const keys = rule.keys ?? undefined;
1660
+ if (!paths.length && !keys) continue;
1661
+ normalized.push({
1662
+ when: rule.when,
1663
+ targets,
1664
+ paths,
1665
+ keys,
1666
+ replacement: rule.replacement ?? replacement,
1667
+ });
1668
+ }
1669
+ return normalized.length ? { replacement, rules: normalized } : null;
1670
+ }
1671
+
1672
+ function maskWhenRequiresTrace(when: ReproMaskWhen): boolean {
1673
+ return Boolean(
1674
+ when.fn ||
1675
+ when.functionName ||
1676
+ when.wrapper ||
1677
+ when.wrapperClass ||
1678
+ when.className ||
1679
+ when.owner ||
1680
+ when.file ||
1681
+ when.lib ||
1682
+ when.library ||
1683
+ when.type ||
1684
+ when.functionType ||
1685
+ when.event ||
1686
+ when.eventType
1687
+ );
1688
+ }
1689
+
1690
+ function matchesMaskWhen(when: ReproMaskWhen | undefined, req: MaskRequestContext, trace: TraceEventForFilter | null): boolean {
1691
+ if (!when) return true;
1692
+ if (!matchesPattern(req.method, when.method)) return false;
1693
+ if (!matchesPattern(req.path, when.path)) return false;
1694
+ if (!matchesPattern(req.key, when.key)) return false;
1695
+
1696
+ if (maskWhenRequiresTrace(when)) {
1697
+ if (!trace) return false;
1698
+ if (!matchesRule(when, trace)) return false;
1699
+ }
1700
+
1701
+ return true;
1702
+ }
1703
+
1704
+ function maskKeysInPlace(node: any, keys: TraceRulePattern, replacement: any) {
1705
+ if (!node) return;
1706
+ if (Array.isArray(node)) {
1707
+ node.forEach(item => maskKeysInPlace(item, keys, replacement));
1708
+ return;
1709
+ }
1710
+ if (typeof node !== 'object') return;
1711
+ Object.keys(node).forEach((key) => {
1712
+ if (matchesPattern(key, keys, false)) {
1713
+ try { node[key] = replacement; } catch {}
1714
+ return;
1715
+ }
1716
+ maskKeysInPlace(node[key], keys, replacement);
1717
+ });
1718
+ }
1719
+
1720
+ function maskPathInPlace(node: any, pathParts: string[], replacement: any, depth: number = 0) {
1721
+ if (!node) return;
1722
+ if (depth >= pathParts.length) return;
1723
+ const part = pathParts[depth];
1724
+ const isLast = depth === pathParts.length - 1;
1725
+
1726
+ const applyAt = (container: any, key: string | number) => {
1727
+ if (!container) return;
1728
+ try { container[key] = replacement; } catch {}
1729
+ };
1730
+
1731
+ if (part === '*') {
1732
+ if (Array.isArray(node)) {
1733
+ for (let i = 0; i < node.length; i++) {
1734
+ if (isLast) applyAt(node, i);
1735
+ else maskPathInPlace(node[i], pathParts, replacement, depth + 1);
1736
+ }
1737
+ } else if (typeof node === 'object') {
1738
+ for (const key of Object.keys(node)) {
1739
+ if (isLast) applyAt(node, key);
1740
+ else maskPathInPlace(node[key], pathParts, replacement, depth + 1);
1741
+ }
1742
+ }
1743
+ return;
1744
+ }
1745
+
1746
+ const index = Number(part);
1747
+ const isIndex = Number.isInteger(index) && String(index) === part;
1748
+ if (Array.isArray(node) && isIndex) {
1749
+ if (index < 0 || index >= node.length) return;
1750
+ if (isLast) applyAt(node, index);
1751
+ else maskPathInPlace(node[index], pathParts, replacement, depth + 1);
1752
+ return;
1753
+ }
1754
+
1755
+ if (typeof node !== 'object') return;
1756
+ if (!Object.prototype.hasOwnProperty.call(node, part)) return;
1757
+ if (isLast) applyAt(node, part);
1758
+ else maskPathInPlace(node[part], pathParts, replacement, depth + 1);
1759
+ }
1760
+
1761
+ function applyMasking(
1762
+ target: ReproMaskTarget,
1763
+ value: any,
1764
+ req: MaskRequestContext,
1765
+ trace: TraceEventForFilter | null,
1766
+ masking: NormalizedMaskingConfig | null
1767
+ ) {
1768
+ if (!masking || !masking.rules.length) return value;
1769
+ if (value === undefined) return value;
1770
+ for (const rule of masking.rules) {
1771
+ if (!rule.targets.includes(target)) continue;
1772
+ if (!matchesMaskWhen(rule.when, req, trace)) continue;
1773
+ const replacement = rule.replacement ?? masking.replacement ?? DEFAULT_MASK_REPLACEMENT;
1774
+ if (rule.keys) maskKeysInPlace(value, rule.keys, replacement);
1775
+ if (rule.paths.length) {
1776
+ rule.paths.forEach(parts => maskPathInPlace(value, parts, replacement, 0));
1777
+ }
1778
+ }
1779
+ return value;
1780
+ }
1781
+
1782
+ // ===================================================================
1783
+ // reproMiddleware — unchanged behavior + passive per-request trace
1784
+ // ===================================================================
1785
+ export type ReproMiddlewareConfig = {
1786
+ appId: string;
1787
+ appSecret: string;
1788
+ appName?: string;
1789
+ /** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
1790
+ captureHeaders?: boolean | HeaderCaptureOptions;
1791
+ /** Optional masking rules for request/response payloads and function traces. */
1792
+ masking?: ReproMaskingConfig;
1793
+ };
1794
+
1795
+ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
1796
+ const masking = normalizeMaskingConfig(cfg.masking);
1797
+ return function (req: Request, res: Response, next: NextFunction) {
1798
+ const sid = (req.headers['x-bug-session-id'] as string) || '';
1799
+ const aid = (req.headers['x-bug-action-id'] as string) || '';
1800
+ if (!sid || !aid) return next(); // only capture tagged requests
1801
+
1802
+ const requestStartRaw = Date.now();
1803
+ const headerTs = readHeaderNumber(req.headers[REQUEST_START_HEADER]);
1804
+ const clockSkewMs = headerTs !== null ? headerTs - requestStartRaw : 0;
1805
+ const requestEpochMs = headerTs !== null ? headerTs : requestStartRaw + clockSkewMs;
1806
+ const rid = nextRequestId(requestEpochMs);
1807
+ const t0 = requestStartRaw;
1808
+ const url = (req as any).originalUrl || req.url || '/';
1809
+ const urlPathOnly = (url || '/').split('?')[0] || '/';
1810
+ const path = url; // back-compat
1811
+ const key = normalizeRouteKey(req.method, url);
1812
+ const maskReq: MaskRequestContext = { method: String(req.method || 'GET').toUpperCase(), path: urlPathOnly, key };
1813
+ const requestHeaders = sanitizeHeaders(req.headers, cfg.captureHeaders);
1814
+ beginSessionRequest(sid);
1815
+
1816
+ // ---- response body capture (unchanged) ----
1817
+ let capturedBody: any = undefined;
1818
+ const origJson = res.json.bind(res as any);
1819
+ (res as any).json = (body: any) => { capturedBody = body; return origJson(body); };
1820
+
1821
+ const origSend = res.send.bind(res as any);
1822
+ (res as any).send = (body: any) => {
1823
+ if (capturedBody === undefined) {
1824
+ capturedBody = coerceBodyToStorable(body, res.getHeader?.('content-type'));
1825
+ }
1826
+ return origSend(body);
1827
+ };
1828
+
1829
+ const origWrite = (res as any).write.bind(res as any);
1830
+ const origEnd = (res as any).end.bind(res as any);
1831
+ const chunks: Array<Buffer | string> = [];
1832
+ (res as any).write = (chunk: any, ...args: any[]) => { try { if (chunk != null) chunks.push(chunk); } catch {} return origWrite(chunk, ...args); };
1833
+ (res as any).end = (chunk?: any, ...args: any[]) => { try { if (chunk != null) chunks.push(chunk); } catch {} return origEnd(chunk, ...args); };
1834
+
1835
+ // ---- our ALS (unchanged) ----
1836
+ const tracerApiWithTrace = __TRACER__ as (TracerApi & { withTrace?: (id: string, fn: () => void) => void }) | null;
1837
+ const runInTrace = (fn: () => void) => {
1838
+ if (tracerApiWithTrace?.withTrace) {
1839
+ return tracerApiWithTrace.withTrace(rid, fn);
1840
+ }
1841
+ return fn();
1842
+ };
1843
+
1844
+ runInTrace(() => als.run({ sid, aid, clockSkewMs, excludedSpanIds: new Set<string>() }, () => {
1845
+ const events: TraceEventRecord[] = [];
1846
+ let endpointTrace: EndpointTraceInfo | null = null;
1847
+ let preferredAppTrace: EndpointTraceInfo | null = null;
1848
+ let firstAppTrace: EndpointTraceInfo | null = null;
1849
+ let unsubscribe: undefined | (() => void);
1850
+ let flushed = false;
1851
+ let finished = false;
1852
+ let finishedAt: number | null = null;
1853
+ let lastEventAt: number = Date.now();
1854
+ let idleTimer: NodeJS.Timeout | null = null;
1855
+ let hardStopTimer: NodeJS.Timeout | null = null;
1856
+ let drainTimer: NodeJS.Timeout | null = null;
1857
+ let flushPayload: null | (() => void) = null;
1858
+ const activeSpans = new Set<string>();
1859
+ let anonymousSpanDepth = 0;
1860
+ const ACTIVE_SPAN_FORCE_FLUSH_MS = 60000; // hard cutoff after finish to avoid endlessly running replies
1861
+
1862
+ const clearTimers = () => {
1863
+ if (idleTimer) {
1864
+ try { clearTimeout(idleTimer); } catch {}
1865
+ idleTimer = null;
1866
+ }
1867
+ if (hardStopTimer) {
1868
+ try { clearTimeout(hardStopTimer); } catch {}
1869
+ hardStopTimer = null;
1870
+ }
1871
+ if (drainTimer) {
1872
+ try { clearTimeout(drainTimer); } catch {}
1873
+ drainTimer = null;
1874
+ }
1875
+ };
1876
+
1877
+ const hasActiveWork = () => activeSpans.size > 0 || anonymousSpanDepth > 0;
1878
+
1879
+ const scheduleIdleFlush = (delay: number = TRACE_IDLE_FLUSH_MS) => {
1880
+ if (!finished || flushed) return;
1881
+ if (hasActiveWork()) return;
1882
+ if (idleTimer) {
1883
+ try { clearTimeout(idleTimer); } catch {}
1884
+ }
1885
+ idleTimer = setTimeout(() => doFlush(false), delay);
1886
+ };
1887
+
1888
+ const doFlush = (force: boolean = false) => {
1889
+ if (flushed) return;
1890
+ const now = Date.now();
1891
+ const stillActive = hasActiveWork();
1892
+ const quietMs = now - lastEventAt;
1893
+ const waitedFinish = finishedAt === null ? 0 : now - finishedAt;
1894
+
1895
+ // If work is still active and we haven't been quiet long enough, defer.
1896
+ if (stillActive && !force) {
1897
+ const remaining = Math.max(0, TRACE_LINGER_AFTER_FINISH_MS - quietMs);
1898
+ scheduleIdleFlush(Math.max(remaining, 10));
1899
+ return;
1900
+ }
1901
+ if (stillActive && force) {
1902
+ // Allow forced flush after either linger window of silence or max guard.
1903
+ if (quietMs < TRACE_LINGER_AFTER_FINISH_MS && waitedFinish < ACTIVE_SPAN_FORCE_FLUSH_MS) {
1904
+ const remainingQuiet = TRACE_LINGER_AFTER_FINISH_MS - quietMs;
1905
+ const remainingGuard = ACTIVE_SPAN_FORCE_FLUSH_MS - waitedFinish;
1906
+ if (hardStopTimer) {
1907
+ try { clearTimeout(hardStopTimer); } catch {}
1908
+ }
1909
+ hardStopTimer = setTimeout(() => doFlush(true), Math.max(10, Math.min(remainingQuiet, remainingGuard)));
1910
+ return;
1911
+ }
1912
+ }
1913
+ flushed = true;
1914
+ clearTimers();
1915
+ try { unsubscribe && unsubscribe(); } catch {}
1916
+ try { flushPayload?.(); } catch {}
1917
+ };
1918
+
1919
+ const bumpIdle = () => {
1920
+ if (!finished || flushed) return;
1921
+ if (idleTimer) {
1922
+ try { clearTimeout(idleTimer); } catch {}
1923
+ }
1924
+ scheduleIdleFlush();
1925
+ };
1926
+
1927
+ const normalizeSpanId = (val: any): string | null => {
1928
+ if (val === null || val === undefined) return null;
1929
+ const str = String(val);
1930
+ return str ? str : null;
1931
+ };
1932
+
1933
+ try {
1934
+ if (__TRACER__?.tracer?.on) {
1935
+ const getTid = __TRACER__?.getCurrentTraceId;
1936
+ const tidNow = getTid ? getTid() : null;
1937
+ if (tidNow) {
1938
+ unsubscribe = __TRACER__.tracer.on((ev: any) => {
1939
+ if (!ev || ev.traceId !== tidNow) return;
1940
+
1941
+ const sourceFileForLibrary = typeof ev.sourceFile === 'string' && ev.sourceFile
1942
+ ? String(ev.sourceFile)
1943
+ : null;
1944
+
1945
+ const evt: TraceEventRecord = {
1946
+ t: alignTimestamp(ev.t),
1947
+ type: ev.type,
1948
+ fn: ev.fn,
1949
+ file: ev.file,
1950
+ line: ev.line,
1951
+ depth: ev.depth,
1952
+ spanId: ev.spanId ?? null,
1953
+ parentSpanId: ev.parentSpanId ?? null,
1954
+ };
1955
+
1956
+ if (ev.functionType !== undefined) {
1957
+ evt.functionType = ev.functionType;
1958
+ }
1959
+
1960
+ const candidate: TraceEventForFilter = {
1961
+ type: evt.type,
1962
+ eventType: evt.type,
1963
+ functionType: evt.functionType ?? null,
1964
+ fn: evt.fn,
1965
+ wrapperClass: inferWrapperClassFromFn(evt.fn),
1966
+ file: evt.file ?? null,
1967
+ line: evt.line ?? null,
1968
+ depth: evt.depth,
1969
+ library: inferLibraryNameFromFile(sourceFileForLibrary ?? evt.file),
1970
+ };
1971
+
1972
+ if (ev.args !== undefined) {
1973
+ evt.args = applyMasking(
1974
+ 'trace.args',
1975
+ sanitizeTraceArgs(ev.args),
1976
+ maskReq,
1977
+ candidate,
1978
+ masking
1979
+ );
1980
+ }
1981
+ if (ev.returnValue !== undefined) {
1982
+ evt.returnValue = applyMasking(
1983
+ 'trace.returnValue',
1984
+ sanitizeTraceValue(ev.returnValue),
1985
+ maskReq,
1986
+ candidate,
1987
+ masking
1988
+ );
1989
+ }
1990
+ if (ev.error !== undefined) {
1991
+ evt.error = applyMasking(
1992
+ 'trace.error',
1993
+ sanitizeTraceValue(ev.error),
1994
+ maskReq,
1995
+ candidate,
1996
+ masking
1997
+ );
1998
+ }
1999
+ if (ev.threw !== undefined) {
2000
+ evt.threw = Boolean(ev.threw);
2001
+ }
2002
+ if (ev.unawaited !== undefined) {
2003
+ evt.unawaited = ev.unawaited === true;
2004
+ }
2005
+
2006
+ const dropEvent = shouldDropTraceEvent(candidate);
2007
+ const dropSpanTree = shouldPropagateUserDisabledFunctionTrace(candidate);
2008
+ const spanKey = normalizeSpanId(evt.spanId);
2009
+ const parentSpanKey = normalizeSpanId(evt.parentSpanId);
2010
+ const isChildOfExcluded = parentSpanKey ? isExcludedSpanId(parentSpanKey) : false;
2011
+ const isExcluded = spanKey ? isExcludedSpanId(spanKey) : false;
2012
+ if (evt.type === 'enter') {
2013
+ lastEventAt = Date.now();
2014
+ if (spanKey) {
2015
+ activeSpans.add(spanKey);
2016
+ } else {
2017
+ anonymousSpanDepth = Math.max(0, anonymousSpanDepth + 1);
2018
+ }
2019
+ } else if (evt.type === 'exit') {
2020
+ lastEventAt = Date.now();
2021
+ if (spanKey && activeSpans.has(spanKey)) {
2022
+ activeSpans.delete(spanKey);
2023
+ } else if (!spanKey && anonymousSpanDepth > 0) {
2024
+ anonymousSpanDepth = Math.max(0, anonymousSpanDepth - 1);
2025
+ }
2026
+ }
2027
+
2028
+ if (dropEvent || isChildOfExcluded || isExcluded) {
2029
+ if (evt.type === 'enter' && spanKey && (dropSpanTree || isChildOfExcluded || isExcluded)) {
2030
+ try { (getCtx() as Ctx).excludedSpanIds?.add(spanKey); } catch {}
2031
+ }
2032
+ if (finished) {
2033
+ scheduleIdleFlush();
2034
+ }
2035
+ return;
2036
+ }
2037
+
2038
+ if (finished) {
2039
+ scheduleIdleFlush();
2040
+ }
2041
+
2042
+ if (evt.type === 'enter' && isLikelyAppFile(evt.file)) {
2043
+ const depthOk = evt.depth === undefined || evt.depth <= 6;
2044
+ const trace = toEndpointTrace(evt);
2045
+
2046
+ if (depthOk && !firstAppTrace) {
2047
+ firstAppTrace = trace;
2048
+ }
2049
+
2050
+ if (isLikelyNestControllerFile(evt.file)) {
2051
+ endpointTrace = trace;
2052
+ } else if (depthOk && !preferredAppTrace && !isLikelyNestGuardFile(evt.file)) {
2053
+ preferredAppTrace = trace;
2054
+ }
2055
+ }
2056
+
2057
+ events.push(evt);
2058
+ bumpIdle();
2059
+ });
2060
+ }
2061
+ }
2062
+ } catch { /* never break user code */ }
2063
+
2064
+ const handleDone = () => {
2065
+ if (finished && flushed) return;
2066
+ finished = true;
2067
+ if (finishedAt === null) {
2068
+ finishedAt = Date.now();
2069
+ }
2070
+ if (capturedBody === undefined && chunks.length) {
2071
+ const buf = Buffer.isBuffer(chunks[0])
2072
+ ? Buffer.concat(chunks.map(c => (Buffer.isBuffer(c) ? c : Buffer.from(String(c)))))
2073
+ : Buffer.from(chunks.map(String).join(''));
2074
+ capturedBody = coerceBodyToStorable(buf, res.getHeader?.('content-type'));
2075
+ }
2076
+
2077
+ if (!flushPayload) {
2078
+ flushPayload = () => {
2079
+ const baseEvents = balanceTraceEvents(events.slice());
2080
+ const orderedEvents = TRACE_ORDER_MODE === 'tree'
2081
+ ? reorderTraceEvents(baseEvents)
2082
+ : sortTraceEventsChronologically(baseEvents);
2083
+ const summary = summarizeEndpointFromEvents(orderedEvents);
2084
+ const chosenEndpoint = summary.endpointTrace
2085
+ ?? summary.preferredAppTrace
2086
+ ?? summary.firstAppTrace
2087
+ ?? endpointTrace
2088
+ ?? preferredAppTrace
2089
+ ?? firstAppTrace
2090
+ ?? { fn: null, file: null, line: null, functionType: null };
2091
+ const traceBatches = chunkArray(orderedEvents, TRACE_BATCH_SIZE);
2092
+ const endpointTraceCtx: TraceEventForFilter | null = (() => {
2093
+ if (!chosenEndpoint?.fn && !chosenEndpoint?.file) return null;
2094
+ return {
2095
+ type: 'enter',
2096
+ eventType: 'enter',
2097
+ fn: chosenEndpoint.fn ?? undefined,
2098
+ wrapperClass: inferWrapperClassFromFn(chosenEndpoint.fn),
2099
+ file: chosenEndpoint.file ?? null,
2100
+ line: chosenEndpoint.line ?? null,
2101
+ functionType: chosenEndpoint.functionType ?? null,
2102
+ library: inferLibraryNameFromFile(chosenEndpoint.file),
2103
+ };
2104
+ })();
2105
+
2106
+ const requestBody = applyMasking(
2107
+ 'request.body',
2108
+ sanitizeRequestSnapshot((req as any).body),
2109
+ maskReq,
2110
+ endpointTraceCtx,
2111
+ masking
2112
+ );
2113
+ const requestParams = applyMasking(
2114
+ 'request.params',
2115
+ sanitizeRequestSnapshot((req as any).params),
2116
+ maskReq,
2117
+ endpointTraceCtx,
2118
+ masking
2119
+ );
2120
+ const requestQuery = applyMasking(
2121
+ 'request.query',
2122
+ sanitizeRequestSnapshot((req as any).query),
2123
+ maskReq,
2124
+ endpointTraceCtx,
2125
+ masking
2126
+ );
2127
+ const maskedHeaders = applyMasking(
2128
+ 'request.headers',
2129
+ requestHeaders,
2130
+ maskReq,
2131
+ endpointTraceCtx,
2132
+ masking
2133
+ );
2134
+ const responseBody = applyMasking(
2135
+ 'response.body',
2136
+ capturedBody === undefined ? undefined : sanitizeRequestSnapshot(capturedBody),
2137
+ maskReq,
2138
+ endpointTraceCtx,
2139
+ masking
2140
+ );
2141
+
2142
+ const requestPayload: Record<string, any> = {
2143
+ rid,
2144
+ method: req.method,
2145
+ url,
2146
+ path,
2147
+ status: res.statusCode,
2148
+ durMs: Date.now() - t0,
2149
+ headers: maskedHeaders,
2150
+ key,
2151
+ respBody: responseBody,
2152
+ trace: traceBatches.length ? undefined : '[]',
2153
+ };
2154
+ if (requestBody !== undefined) requestPayload.body = requestBody;
2155
+ if (requestParams !== undefined) requestPayload.params = requestParams;
2156
+ if (requestQuery !== undefined) requestPayload.query = requestQuery;
2157
+ requestPayload.entryPoint = chosenEndpoint;
2158
+
2159
+ post(cfg, sid, {
2160
+ entries: [{
2161
+ actionId: aid,
2162
+ request: requestPayload,
2163
+ t: alignedNow(),
2164
+ }]
2165
+ });
2166
+
2167
+ if (traceBatches.length) {
2168
+ for (let i = 0; i < traceBatches.length; i++) {
2169
+ const batch = traceBatches[i];
2170
+ let traceStr = '[]';
2171
+ try { traceStr = JSON.stringify(batch); } catch {}
2172
+
2173
+ post(cfg, sid, {
2174
+ entries: [{
2175
+ actionId: aid,
2176
+ trace: traceStr,
2177
+ traceBatch: {
2178
+ rid,
2179
+ index: i,
2180
+ total: traceBatches.length,
2181
+ },
2182
+ t: alignedNow(),
2183
+ }],
2184
+ });
2185
+ }
2186
+ }
2187
+ };
2188
+ }
2189
+
2190
+ const waitDrain = waitForSessionDrain(sid);
2191
+ endSessionRequest(sid);
2192
+
2193
+ if (SESSION_DRAIN_TIMEOUT_MS > 0) {
2194
+ drainTimer = setTimeout(() => doFlush(true), SESSION_DRAIN_TIMEOUT_MS);
2195
+ }
2196
+
2197
+ waitDrain.then(() => {
2198
+ if (drainTimer) {
2199
+ try { clearTimeout(drainTimer); } catch {}
2200
+ drainTimer = null;
2201
+ }
2202
+ if (__TRACER_READY) {
2203
+ bumpIdle();
2204
+ const hardDeadlineMs = Math.max(
2205
+ TRACE_FLUSH_DELAY_MS + TRACE_LINGER_AFTER_FINISH_MS,
2206
+ TRACE_IDLE_FLUSH_MS + TRACE_FLUSH_DELAY_MS,
2207
+ );
2208
+ hardStopTimer = setTimeout(() => doFlush(true), hardDeadlineMs);
2209
+ } else {
2210
+ doFlush(true);
2211
+ }
2212
+ }).catch(() => {
2213
+ doFlush(true);
2214
+ });
2215
+ };
2216
+
2217
+ res.on('finish', handleDone);
2218
+ res.on('close', handleDone);
2219
+
2220
+ next();
2221
+ }));
2222
+ };
2223
+ }
2224
+
2225
+ // ===================================================================
2226
+ // reproMongoosePlugin — stable + NON-intrusive query logs
2227
+ // - NO prototype monkey-patching of Mongoose
2228
+ // - ONLY schema middleware (pre/post) for specific ops
2229
+ // - keeps your existing doc-diff hooks
2230
+ // ===================================================================
2231
+ export function reproMongoosePlugin(cfg: { appId: string; appSecret: string; appName?: string }) {
2232
+ return function (schema: Schema) {
2233
+ // -------- pre/post save (unchanged) --------
2234
+ schema.pre('save', { document: true }, async function (next) {
2235
+ const { sid, aid } = getCtx();
2236
+ if (!sid || !aid) return next();
2237
+ if ((this as any).$isSubdocument) return next();
2238
+
2239
+ let before: any = null;
2240
+ try {
2241
+ if (!this.isNew) {
2242
+ const model = this.constructor as Model<any>;
2243
+ before = await model.findById(this._id).lean().exec();
2244
+ }
2245
+ } catch {}
2246
+ (this as any).__repro_meta = {
2247
+ wasNew: this.isNew,
2248
+ before,
2249
+ collection: resolveCollectionOrWarn(this, 'doc'),
2250
+ spanContext: captureSpanContextFromTracer(this),
2251
+ };
2252
+ next();
2253
+ });
2254
+
2255
+ schema.post('save', { document: true }, function () {
2256
+ const { sid, aid } = getCtx();
2257
+ if (!sid || !aid) return;
2258
+ if ((this as any).$isSubdocument) return;
2259
+
2260
+ const meta = (this as any).__repro_meta || {};
2261
+ const before = meta.before ?? null;
2262
+ const after = this.toObject({ depopulate: true });
2263
+ const collection = meta.collection || resolveCollectionOrWarn(this, 'doc');
2264
+ const spanContext = meta.spanContext || captureSpanContextFromTracer(this);
2265
+ if (!shouldCaptureDbSpan(spanContext)) return;
2266
+
2267
+ const query = meta.wasNew
2268
+ ? { op: 'insertOne', doc: after }
2269
+ : { filter: { _id: this._id }, update: buildMinimalUpdate(before, after), options: { upsert: false } };
2270
+
2271
+ post(cfg, (getCtx() as Ctx).sid!, {
2272
+ entries: [{
2273
+ actionId: (getCtx() as Ctx).aid!,
2274
+ db: [attachSpanContext({
2275
+ collection,
2276
+ pk: { _id: (this as any)._id },
2277
+ before,
2278
+ after,
2279
+ op: meta.wasNew ? 'insert' : 'update',
2280
+ query,
2281
+ }, spanContext)],
2282
+ t: alignedNow(),
2283
+ }]
2284
+ });
2285
+ });
2286
+
2287
+ // -------- findOneAndUpdate capture (unchanged) --------
2288
+ schema.pre<Query<any, any>>('findOneAndUpdate', async function (next) {
2289
+ const { sid, aid } = getCtx();
2290
+ if (!sid || !aid) return next();
2291
+ try {
2292
+ const filter = this.getFilter();
2293
+ const model = this.model as Model<any>;
2294
+ (this as any).__repro_before = await model.findOne(filter).lean().exec();
2295
+ this.setOptions({ new: true });
2296
+ (this as any).__repro_collection = resolveCollectionOrWarn(this, 'query');
2297
+ (this as any).__repro_spanContext = captureSpanContextFromTracer(this);
2298
+ } catch {}
2299
+ next();
2300
+ });
2301
+
2302
+ schema.post<Query<any, any>>('findOneAndUpdate', function (res: any) {
2303
+ const { sid, aid } = getCtx();
2304
+ if (!sid || !aid) return;
2305
+
2306
+ const before = (this as any).__repro_before ?? null;
2307
+ const after = res ?? null;
2308
+ const collection = (this as any).__repro_collection || resolveCollectionOrWarn(this, 'query');
2309
+ const spanContext = (this as any).__repro_spanContext || captureSpanContextFromTracer(this);
2310
+ if (!shouldCaptureDbSpan(spanContext)) return;
2311
+ const pk = after?._id ?? before?._id;
2312
+
2313
+ post(cfg, (getCtx() as Ctx).sid!, {
2314
+ entries: [{
2315
+ actionId: (getCtx() as Ctx).aid!,
2316
+ db: [attachSpanContext({
2317
+ collection,
2318
+ pk: { _id: pk },
2319
+ before,
2320
+ after,
2321
+ op: after && before ? 'update' : after ? 'insert' : 'update',
2322
+ }, spanContext)],
2323
+ t: alignedNow()
2324
+ }]
2325
+ });
2326
+ });
2327
+
2328
+ // -------- deleteOne capture (unchanged) --------
2329
+ schema.pre<Query<any, any>>('deleteOne', { document: false, query: true }, async function (next) {
2330
+ const { sid, aid } = getCtx(); if (!sid || !aid) return next();
2331
+ try {
2332
+ const filter = this.getFilter();
2333
+ (this as any).__repro_before = await (this.model as Model<any>).findOne(filter).lean().exec();
2334
+ (this as any).__repro_collection = resolveCollectionOrWarn(this, 'query');
2335
+ (this as any).__repro_filter = filter;
2336
+ (this as any).__repro_spanContext = captureSpanContextFromTracer(this);
2337
+ } catch {}
2338
+ next();
2339
+ });
2340
+
2341
+ schema.post<Query<any, any>>('deleteOne', { document: false, query: true }, function () {
2342
+ const { sid, aid } = getCtx(); if (!sid || !aid) return;
2343
+ const before = (this as any).__repro_before ?? null;
2344
+ if (!before) return;
2345
+ const collection = (this as any).__repro_collection || resolveCollectionOrWarn(this, 'query');
2346
+ const filter = (this as any).__repro_filter ?? { _id: before._id };
2347
+ const spanContext = (this as any).__repro_spanContext || captureSpanContextFromTracer(this);
2348
+ if (!shouldCaptureDbSpan(spanContext)) return;
2349
+ post(cfg, (getCtx() as Ctx).sid!, {
2350
+ entries: [{
2351
+ actionId: (getCtx() as Ctx).aid!,
2352
+ db: [attachSpanContext({
2353
+ collection,
2354
+ pk: { _id: before._id },
2355
+ before,
2356
+ after: null,
2357
+ op: 'delete',
2358
+ query: { filter },
2359
+ }, spanContext)],
2360
+ t: alignedNow()
2361
+ }]
2362
+ });
2363
+ });
2364
+
2365
+ // -------- NON-intrusive generic query telemetry via schema hooks -------
2366
+ const sanitizeDbValue = (value: any) => {
2367
+ const sanitized = sanitizeTraceValue(value);
2368
+ return sanitized === undefined ? undefined : sanitized;
2369
+ };
2370
+
2371
+ const READ_OPS = [
2372
+ 'find',
2373
+ 'findOne',
2374
+ 'countDocuments',
2375
+ 'estimatedDocumentCount',
2376
+ 'distinct',
2377
+ ] as const;
2378
+
2379
+ const WRITE_OPS = [
2380
+ 'updateOne',
2381
+ 'updateMany',
2382
+ 'replaceOne',
2383
+ 'deleteMany',
2384
+ 'findOneAndUpdate',
2385
+ 'findOneAndDelete',
2386
+ 'findOneAndRemove',
2387
+ 'findOneAndReplace',
2388
+ 'findByIdAndUpdate',
2389
+ 'findByIdAndDelete',
2390
+ 'findByIdAndRemove',
2391
+ 'findByIdAndReplace',
2392
+ ] as const;
2393
+
2394
+ function attachQueryHooks(op: string) {
2395
+ schema.pre(op as any, function (this: any, next: Function) {
2396
+ try {
2397
+ (this as any).__repro_qmeta = {
2398
+ t0: Date.now(),
2399
+ collection: this?.model?.collection?.name || 'unknown',
2400
+ op,
2401
+ spanContext: captureSpanContextFromTracer(this),
2402
+ filter: sanitizeDbValue(this.getFilter?.() ?? this._conditions ?? undefined),
2403
+ update: sanitizeDbValue(this.getUpdate?.() ?? this._update ?? undefined),
2404
+ projection: sanitizeDbValue(this.projection?.() ?? this._fields ?? undefined),
2405
+ options: sanitizeDbValue(this.getOptions?.() ?? this.options ?? undefined),
2406
+ };
2407
+ } catch {
2408
+ (this as any).__repro_qmeta = {
2409
+ t0: Date.now(),
2410
+ collection: 'unknown',
2411
+ op,
2412
+ spanContext: captureSpanContextFromTracer(this),
2413
+ };
2414
+ }
2415
+ next();
2416
+ });
2417
+
2418
+ schema.post(op as any, function (this: any, res: any) {
2419
+ const { sid, aid } = getCtx();
2420
+ if (!sid) return;
2421
+
2422
+ const meta = (this as any).__repro_qmeta || { t0: Date.now(), collection: 'unknown', op };
2423
+ const resultMeta = summarizeQueryResult(op, res);
2424
+ const spanContext = meta.spanContext || captureSpanContextFromTracer(this);
2425
+
2426
+ emitDbQuery(cfg, sid, aid, {
2427
+ collection: meta.collection,
2428
+ op,
2429
+ query: { filter: meta.filter, update: meta.update, projection: meta.projection, options: meta.options },
2430
+ resultMeta,
2431
+ durMs: Date.now() - meta.t0,
2432
+ t: alignedNow(),
2433
+ spanContext,
2434
+ });
2435
+ });
2436
+ }
2437
+
2438
+ READ_OPS.forEach(attachQueryHooks);
2439
+ WRITE_OPS.forEach(attachQueryHooks);
2440
+
2441
+ // bulkWrite + insertMany (non-query middleware)
2442
+ schema.pre<any>('insertMany' as any, { document: false, query: false } as any, function (this: any, next: Function, docs?: any[]) {
2443
+ try {
2444
+ (this as any).__repro_insert_meta = {
2445
+ t0: Date.now(),
2446
+ collection: this?.collection?.name || this?.model?.collection?.name || 'unknown',
2447
+ docs: sanitizeDbValue(docs),
2448
+ spanContext: captureSpanContextFromTracer(this),
2449
+ };
2450
+ } catch {
2451
+ (this as any).__repro_insert_meta = {
2452
+ t0: Date.now(),
2453
+ collection: 'unknown',
2454
+ spanContext: captureSpanContextFromTracer(this),
2455
+ };
2456
+ }
2457
+ next();
2458
+ } as any);
2459
+
2460
+ schema.post<any>('insertMany' as any, { document: false, query: false } as any, function (this: any, docs: any[]) {
2461
+ const { sid, aid } = getCtx();
2462
+ if (!sid) return;
2463
+ const meta = (this as any).__repro_insert_meta || { t0: Date.now(), collection: 'unknown' };
2464
+ const resultMeta = Array.isArray(docs) ? { inserted: docs.length } : summarizeQueryResult('insertMany', docs);
2465
+ const spanContext = meta.spanContext || captureSpanContextFromTracer(this);
2466
+
2467
+ emitDbQuery(cfg, sid, aid, {
2468
+ collection: meta.collection,
2469
+ op: 'insertMany',
2470
+ query: { docs: meta.docs ?? undefined },
2471
+ resultMeta,
2472
+ durMs: Date.now() - meta.t0,
2473
+ t: alignedNow(),
2474
+ spanContext,
2475
+ });
2476
+ } as any);
2477
+
2478
+ schema.pre<any>('bulkWrite' as any, { document: false, query: false } as any, function (this: any, next: Function, ops?: any[]) {
2479
+ try {
2480
+ (this as any).__repro_bulk_meta = {
2481
+ t0: Date.now(),
2482
+ collection: this?.collection?.name || this?.model?.collection?.name || 'unknown',
2483
+ ops: sanitizeDbValue(ops),
2484
+ spanContext: captureSpanContextFromTracer(this),
2485
+ };
2486
+ } catch {
2487
+ (this as any).__repro_bulk_meta = {
2488
+ t0: Date.now(),
2489
+ collection: 'unknown',
2490
+ spanContext: captureSpanContextFromTracer(this),
2491
+ };
2492
+ }
2493
+ next();
2494
+ } as any);
2495
+
2496
+ schema.post<any>('bulkWrite' as any, { document: false, query: false } as any, function (this: any, res: any) {
2497
+ const { sid, aid } = getCtx();
2498
+ if (!sid) return;
2499
+ const meta = (this as any).__repro_bulk_meta || { t0: Date.now(), collection: 'unknown' };
2500
+ const bulkResult = summarizeBulkResult(res);
2501
+ const resultMeta = { ...bulkResult, result: sanitizeResultForMeta(res?.result ?? res) };
2502
+ const spanContext = meta.spanContext || captureSpanContextFromTracer(this);
2503
+
2504
+ emitDbQuery(cfg, sid, aid, {
2505
+ collection: meta.collection,
2506
+ op: 'bulkWrite',
2507
+ query: { ops: meta.ops ?? undefined },
2508
+ resultMeta,
2509
+ durMs: Date.now() - meta.t0,
2510
+ t: alignedNow(),
2511
+ spanContext,
2512
+ });
2513
+ } as any);
2514
+
2515
+ // Aggregate middleware (non-intrusive)
2516
+ schema.pre('aggregate', function (this: any, next: Function) {
2517
+ try {
2518
+ (this as any).__repro_aggmeta = {
2519
+ t0: Date.now(),
2520
+ collection:
2521
+ this?.model?.collection?.name ||
2522
+ this?._model?.collection?.name ||
2523
+ (this?.model && this.model.collection?.name) ||
2524
+ 'unknown',
2525
+ spanContext: captureSpanContextFromTracer(this),
2526
+ pipeline: sanitizeDbValue(this.pipeline?.() ?? this._pipeline ?? undefined),
2527
+ };
2528
+ } catch {
2529
+ (this as any).__repro_aggmeta = {
2530
+ t0: Date.now(),
2531
+ collection: 'unknown',
2532
+ pipeline: undefined,
2533
+ spanContext: captureSpanContextFromTracer(this),
2534
+ };
2535
+ }
2536
+ next();
2537
+ });
2538
+
2539
+ schema.post('aggregate', function (this: any, res: any[]) {
2540
+ const { sid, aid } = getCtx();
2541
+ if (!sid) return;
2542
+
2543
+ const meta = (this as any).__repro_aggmeta || { t0: Date.now(), collection: 'unknown' };
2544
+ const resultMeta = summarizeQueryResult('aggregate', res);
2545
+ const spanContext = meta.spanContext || captureSpanContextFromTracer(this);
2546
+
2547
+ emitDbQuery(cfg, sid, aid, {
2548
+ collection: meta.collection,
2549
+ op: 'aggregate',
2550
+ query: { pipeline: meta.pipeline },
2551
+ resultMeta,
2552
+ durMs: Date.now() - meta.t0,
2553
+ t: alignedNow(),
2554
+ spanContext,
2555
+ });
2556
+ });
2557
+ };
2558
+ }
2559
+
2560
+ function summarizeQueryResult(op: string, res: any) {
2561
+ const resultPreview = sanitizeResultForMeta(res);
2562
+
2563
+ if (
2564
+ op === 'find' ||
2565
+ op === 'findOne' ||
2566
+ op === 'aggregate' ||
2567
+ op === 'distinct' ||
2568
+ op.startsWith('count')
2569
+ ) {
2570
+ const summary: Record<string, any> = {};
2571
+ if (Array.isArray(res)) summary.docsCount = res.length;
2572
+ else if (res && typeof res === 'object' && typeof (res as any).toArray === 'function') summary.docsCount = undefined;
2573
+ else if (res == null) summary.docsCount = 0;
2574
+ else summary.docsCount = 1;
2575
+
2576
+ if (typeof resultPreview !== 'undefined') {
2577
+ summary.result = resultPreview;
2578
+ }
2579
+ return summary;
2580
+ }
2581
+
2582
+ if (op === 'insertMany') {
2583
+ const summary: Record<string, any> = {};
2584
+ if (Array.isArray(res)) summary.inserted = res.length;
2585
+ if (typeof resultPreview !== 'undefined') summary.result = resultPreview;
2586
+ return summary;
2587
+ }
2588
+
2589
+ if (op === 'bulkWrite') {
2590
+ return { ...summarizeBulkResult(res), result: resultPreview };
2591
+ }
2592
+
2593
+ const stats = pickWriteStats(res);
2594
+ if (typeof resultPreview !== 'undefined') {
2595
+ return { ...stats, result: resultPreview };
2596
+ }
2597
+ return stats;
2598
+ }
2599
+
2600
+ function summarizeBulkResult(res: any) {
2601
+ return {
2602
+ matched: res?.matchedCount ?? res?.nMatched ?? undefined,
2603
+ modified: res?.modifiedCount ?? res?.nModified ?? undefined,
2604
+ upserted: res?.upsertedCount ?? undefined,
2605
+ deleted: res?.deletedCount ?? undefined,
2606
+ };
2607
+ }
2608
+
2609
+ function pickWriteStats(r: any) {
2610
+ return {
2611
+ matched: r?.matchedCount ?? r?.n ?? r?.nMatched ?? undefined,
2612
+ modified: r?.modifiedCount ?? r?.nModified ?? undefined,
2613
+ upsertedId: r?.upsertedId ?? r?.upserted?._id ?? undefined,
2614
+ deleted: r?.deletedCount ?? undefined,
2615
+ };
2616
+ }
2617
+
2618
+ function safeJson(v: any) {
2619
+ try { return v == null ? undefined : JSON.parse(JSON.stringify(v)); } catch { return undefined; }
2620
+ }
2621
+
2622
+ function sanitizeResultForMeta(value: any) {
2623
+ if (value === undefined) return undefined;
2624
+ if (typeof value === 'function') return undefined;
2625
+ try {
2626
+ return sanitizeTraceValue(value);
2627
+ } catch {
2628
+ const fallback = safeJson(value);
2629
+ return fallback === undefined ? undefined : fallback;
2630
+ }
2631
+ }
2632
+
2633
+ function dehydrateComplexValue(value: any) {
2634
+ if (!value || typeof value !== 'object') return value;
2635
+ if (Array.isArray(value)) return value;
2636
+ if (value instanceof Date || value instanceof RegExp || Buffer.isBuffer(value)) return value;
2637
+ if (value instanceof Map || value instanceof Set) return value;
2638
+
2639
+ try {
2640
+ if (typeof (value as any).toJSON === 'function') {
2641
+ const plain = (value as any).toJSON();
2642
+ if (plain && plain !== value) return plain;
2643
+ }
2644
+ } catch {}
2645
+
2646
+ try {
2647
+ if (typeof (value as any).toObject === 'function') {
2648
+ const plain = (value as any).toObject();
2649
+ if (plain && plain !== value) return plain;
2650
+ }
2651
+ } catch {}
2652
+
2653
+ const ctor = (value as any)?.constructor?.name;
2654
+ if (ctor && ctor !== 'Object') {
2655
+ const plain = safeJson(value);
2656
+ if (plain !== undefined) return plain;
2657
+ }
2658
+
2659
+ return value;
2660
+ }
2661
+
2662
+ function emitDbQuery(cfg: any, sid?: string, aid?: string, payload?: any) {
2663
+ if (!sid) return;
2664
+ const spanContext = payload?.spanContext ?? captureSpanContextFromTracer();
2665
+ if (!shouldCaptureDbSpan(spanContext)) return;
2666
+ const dbEntry = attachSpanContext({
2667
+ collection: payload.collection,
2668
+ op: payload.op,
2669
+ query: payload.query ?? undefined,
2670
+ resultMeta: payload.resultMeta ?? undefined,
2671
+ durMs: payload.durMs ?? undefined,
2672
+ pk: null, before: null, after: null,
2673
+ error: payload.error ?? undefined,
2674
+ }, spanContext);
2675
+ post(cfg, sid, {
2676
+ entries: [{
2677
+ actionId: aid ?? null,
2678
+ db: [dbEntry],
2679
+ t: payload.t,
2680
+ }]
2681
+ });
2682
+ }
2683
+
2684
+ function buildMinimalUpdate(before: any, after: any) {
2685
+ const set: Record<string, any> = {};
2686
+ const unset: Record<string, any> = {};
2687
+
2688
+ function walk(b: any, a: any, path = '') {
2689
+ const bKeys = b ? Object.keys(b) : [];
2690
+ const aKeys = a ? Object.keys(a) : [];
2691
+ const all = new Set([...bKeys, ...aKeys]);
2692
+ for (const k of all) {
2693
+ const p = path ? `${path}.${k}` : k;
2694
+ const bv = b?.[k];
2695
+ const av = a?.[k];
2696
+
2697
+ const bothObj =
2698
+ bv && av &&
2699
+ typeof bv === 'object' &&
2700
+ typeof av === 'object' &&
2701
+ !Array.isArray(bv) &&
2702
+ !Array.isArray(av);
2703
+
2704
+ if (bothObj) {
2705
+ walk(bv, av, p);
2706
+ } else if (typeof av === 'undefined') {
2707
+ unset[p] = '';
2708
+ } else if (JSON.stringify(bv) !== JSON.stringify(av)) {
2709
+ set[p] = av;
2710
+ }
2711
+ }
2712
+ }
2713
+
2714
+ walk(before || {}, after || {});
2715
+ const update: any = {};
2716
+ if (Object.keys(set).length) update.$set = set;
2717
+ if (Object.keys(unset).length) update.$unset = unset;
2718
+ return update;
2719
+ }
2720
+
2721
+ // ===================================================================
2722
+ // Sendgrid — unchanged
2723
+ // ===================================================================
2724
+ export type SendgridPatchConfig = {
2725
+ appId: string;
2726
+ appSecret: string;
2727
+ appName?: string;
2728
+ resolveContext?: () => { sid?: string; aid?: string } | undefined;
2729
+ };
2730
+
2731
+ export function patchSendgridMail(cfg: SendgridPatchConfig) {
2732
+ let sgMail: any;
2733
+ try { sgMail = require('@sendgrid/mail'); } catch { return; } // no-op if not installed
2734
+
2735
+ if (!sgMail || (sgMail as any).__repro_patched) return;
2736
+ (sgMail as any).__repro_patched = true;
2737
+
2738
+ const origSend = sgMail.send?.bind(sgMail);
2739
+ const origSendMultiple = sgMail.sendMultiple?.bind(sgMail);
2740
+
2741
+ if (origSend) {
2742
+ sgMail.send = async function patchedSend(msg: any, isMultiple?: boolean) {
2743
+ const t0 = Date.now();
2744
+ let statusCode: number | undefined;
2745
+ let headers: Record<string, any> | undefined;
2746
+ try {
2747
+ const res = await origSend(msg, isMultiple);
2748
+ const r = Array.isArray(res) ? res[0] : res;
2749
+ statusCode = r?.statusCode ?? r?.status;
2750
+ headers = r?.headers ?? undefined;
2751
+ return res;
2752
+ } finally {
2753
+ fireCapture('send', msg, t0, statusCode, headers);
2754
+ }
2755
+ };
2756
+ }
2757
+
2758
+ if (origSendMultiple) {
2759
+ sgMail.sendMultiple = async function patchedSendMultiple(msg: any) {
2760
+ const t0 = Date.now();
2761
+ let statusCode: number | undefined;
2762
+ let headers: Record<string, any> | undefined;
2763
+ try {
2764
+ const res = await origSendMultiple(msg);
2765
+ const r = Array.isArray(res) ? res[0] : res;
2766
+ statusCode = r?.statusCode ?? r?.status;
2767
+ headers = r?.headers ?? undefined;
2768
+ return res;
2769
+ } finally {
2770
+ fireCapture('sendMultiple', msg, t0, statusCode, headers);
2771
+ }
2772
+ };
2773
+ }
2774
+
2775
+ function fireCapture(kind: 'send' | 'sendMultiple', rawMsg: any, t0: number, statusCode?: number, headers?: any) {
2776
+ const ctx = getCtx();
2777
+ const sid = ctx.sid ?? cfg.resolveContext?.()?.sid;
2778
+ const aid = ctx.aid ?? cfg.resolveContext?.()?.aid;
2779
+ if (!sid) return;
2780
+
2781
+ const norm = normalizeSendgridMessage(rawMsg);
2782
+ post(cfg, sid, {
2783
+ entries: [{
2784
+ actionId: aid ?? null,
2785
+ email: {
2786
+ provider: 'sendgrid',
2787
+ kind,
2788
+ to: norm.to, cc: norm.cc, bcc: norm.bcc, from: norm.from,
2789
+ subject: norm.subject, text: norm.text, html: norm.html,
2790
+ templateId: norm.templateId, dynamicTemplateData: norm.dynamicTemplateData,
2791
+ categories: norm.categories, customArgs: norm.customArgs,
2792
+ attachmentsMeta: norm.attachmentsMeta,
2793
+ statusCode, durMs: Date.now() - t0, headers: headers ?? {},
2794
+ },
2795
+ t: alignedNow(),
2796
+ }]
2797
+ });
2798
+ }
2799
+
2800
+ function normalizeAddress(a: any): { email: string; name?: string } | null {
2801
+ if (!a) return null;
2802
+ if (typeof a === 'string') return { email: a };
2803
+ if (typeof a === 'object' && a.email) return { email: String(a.email), name: a.name ? String(a.name) : undefined };
2804
+ return null;
2805
+ }
2806
+ function normalizeAddressList(v: any) {
2807
+ if (!v) return undefined;
2808
+ const arr = Array.isArray(v) ? v : [v];
2809
+ const out = arr.map(normalizeAddress).filter(Boolean) as Array<{ email: string; name?: string }>;
2810
+ return out.length ? out : undefined;
2811
+ }
2812
+ function normalizeSendgridMessage(msg: any) {
2813
+ const base = {
2814
+ from: normalizeAddress(msg?.from) ?? undefined,
2815
+ to: normalizeAddressList(msg?.to),
2816
+ cc: normalizeAddressList(msg?.cc),
2817
+ bcc: normalizeAddressList(msg?.bcc),
2818
+ subject: msg?.subject ? String(msg.subject) : undefined,
2819
+ text: typeof msg?.text === 'string' ? msg.text : undefined,
2820
+ html: typeof msg?.html === 'string' ? msg.html : undefined,
2821
+ templateId: msg?.templateId ? String(msg.templateId) : undefined,
2822
+ dynamicTemplateData: msg?.dynamic_template_data ?? msg?.dynamicTemplateData ?? undefined,
2823
+ categories: Array.isArray(msg?.categories) ? msg.categories.map(String) : undefined,
2824
+ customArgs: msg?.customArgs ?? msg?.custom_args ?? undefined,
2825
+ attachmentsMeta: Array.isArray(msg?.attachments)
2826
+ ? msg.attachments.map((a: any) => ({
2827
+ filename: a?.filename ? String(a.filename) : undefined,
2828
+ type: a?.type ? String(a.type) : undefined,
2829
+ size: a?.content ? byteLen(a.content) : undefined,
2830
+ }))
2831
+ : undefined,
2832
+ };
2833
+ const p0 = Array.isArray(msg?.personalizations) ? msg.personalizations[0] : undefined;
2834
+ if (p0) {
2835
+ base.to = normalizeAddressList(p0.to) ?? base.to;
2836
+ base.cc = normalizeAddressList(p0.cc) ?? base.cc;
2837
+ base.bcc = normalizeAddressList(p0.bcc) ?? base.bcc;
2838
+ if (!base.subject && p0.subject) base.subject = String(p0.subject);
2839
+ if (!base.dynamicTemplateData && (p0 as any).dynamic_template_data) base.dynamicTemplateData = (p0 as any).dynamic_template_data;
2840
+ if (!base.customArgs && (p0 as any).custom_args) base.customArgs = (p0 as any).custom_args;
2841
+ }
2842
+ return base;
2843
+ }
2844
+ function byteLen(content: any): number | undefined {
2845
+ try {
2846
+ if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
2847
+ if (content && typeof content === 'object' && 'length' in content) return Number((content as any).length);
2848
+ } catch {}
2849
+ return undefined;
2850
+ }
2851
+ }