@reproapp/node-sdk 0.0.49 → 0.0.50
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/README.md +44 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +62 -6
- package/dist/ingest/client.js +27 -2
- package/dist/live-capture-limits.d.ts +19 -0
- package/dist/live-capture-limits.js +207 -0
- package/dist/semantic-serialization.d.ts +1 -0
- package/dist/semantic-serialization.js +33 -1
- package/package.json +6 -1
- package/src/index.ts +75 -6
- package/src/ingest/client.ts +27 -3
- package/src/live-capture-limits.ts +177 -0
- package/src/semantic-serialization.ts +30 -0
- package/test/fixtures/live-capture-limits-server.js +21 -0
- package/test/ingest-throttle-retention.test.js +41 -0
- package/test/live-broker-limits.testcontainers.test.js +86 -0
- package/test/live-capture-limits.test.js +99 -0
- package/test/live-capture-limits.testcontainers.test.js +78 -0
package/README.md
CHANGED
|
@@ -107,6 +107,7 @@ await initRepro({
|
|
|
107
107
|
appName: "Acme Web",
|
|
108
108
|
serviceName: "api",
|
|
109
109
|
apiBase: process.env.REPRO_API_BASE,
|
|
110
|
+
ingestBase: process.env.REPRO_INGEST_BASE!,
|
|
110
111
|
});
|
|
111
112
|
```
|
|
112
113
|
|
|
@@ -123,14 +124,56 @@ await initRepro({
|
|
|
123
124
|
liveMode: {
|
|
124
125
|
enabled: true,
|
|
125
126
|
backendOnly: true,
|
|
126
|
-
|
|
127
|
+
// Trust only a header that your authenticated gateway overwrites.
|
|
128
|
+
defineActorId: ({ req }) => req.headers["x-user-id"] ?? null,
|
|
127
129
|
shouldCapture: ({ path }) => path.startsWith("/api/") && !path.includes("/health"),
|
|
128
130
|
actorType: "user",
|
|
129
131
|
actorLabels: ({ path }) => ({ routeGroup: path.split("/").filter(Boolean)[0] ?? "api" }),
|
|
132
|
+
liveCapture: { sampleRate: 0.1, targets: [] },
|
|
130
133
|
},
|
|
131
134
|
});
|
|
132
135
|
```
|
|
133
136
|
|
|
137
|
+
### Sampling, limits and runtime targets
|
|
138
|
+
|
|
139
|
+
Backend-owned live capture accepts `liveMode.liveCapture.sampleRate` from 0 to 1
|
|
140
|
+
(default 1 for compatibility). A chain keeps its sample decision until five minutes
|
|
141
|
+
of inactivity. Actor identity separates chains; three-minute child segments admit
|
|
142
|
+
new work. Admitted requests and their queue/DB/trace tails stay in the original
|
|
143
|
+
segment even when delivery finishes later. Browser-correlated captures use the
|
|
144
|
+
browser's admission decision, rather than sampling a second time.
|
|
145
|
+
|
|
146
|
+
Keep the `liveCapture` object in your runtime configuration and replace its `targets`
|
|
147
|
+
array when a support case starts or ends. Example target entries:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
const liveCapture = {
|
|
151
|
+
sampleRate: 0.1,
|
|
152
|
+
targets: [
|
|
153
|
+
{ actorIds: ['user-123'], expiresAt: Date.now() + 3600000 },
|
|
154
|
+
{ labels: { routeGroup: 'reports' }, expiresAt: Date.now() + 3600000 },
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
// Pass this object as liveMode.liveCapture; update liveCapture.targets at runtime.
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Targets override sampling, including rate 0, but never quota admission. All labels
|
|
161
|
+
in one target must match; actor IDs and label selectors are alternatives. Use a
|
|
162
|
+
trusted identity source, not arbitrary client-controlled headers.
|
|
163
|
+
|
|
164
|
+
Live business values have depth 10, 100 keys per object, and 100 entries per array,
|
|
165
|
+
Map, Set or native collection. Partial captures carry explicit omitted path/reason/
|
|
166
|
+
count metadata. `captureRaw: true` bypasses privacy processing, not live limits.
|
|
167
|
+
Staging business values are unchanged. Both modes project recognized runtime
|
|
168
|
+
infrastructure (connections, pools, framework/configuration machinery) into compact
|
|
169
|
+
descriptions; business documents, queries, results and application payloads remain
|
|
170
|
+
evidence. Unknown objects are treated as business data.
|
|
171
|
+
|
|
172
|
+
HTTP 429 honors `Retry-After` without dropping critical uploads. Quota refusal (402)
|
|
173
|
+
and deleted/failed capture refusal (410) stop retrying that refused batch; business
|
|
174
|
+
requests continue. Quota-enabled installations must use the configured collector,
|
|
175
|
+
not the legacy direct API evidence endpoints.
|
|
176
|
+
|
|
134
177
|
## Manual Wiring
|
|
135
178
|
|
|
136
179
|
Use the lower-level exports when auto-attach is not appropriate or when you need
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { Schema } from 'mongoose';
|
|
|
28
28
|
import { type IngestClientConfig } from './ingest/client';
|
|
29
29
|
import { type NormalizedRuntimePrivacyPolicy, type RuntimePrivacyConfig, type RuntimePrivacyDbContext, type RuntimePrivacySurface } from './privacy';
|
|
30
30
|
import { projectSemanticInfrastructureValue } from './semantic-serialization';
|
|
31
|
+
import { type CaptureOmission } from './live-capture-limits';
|
|
31
32
|
type SpanContext = {
|
|
32
33
|
traceId: string | null;
|
|
33
34
|
spanId: string | number | null;
|
|
@@ -88,6 +89,8 @@ type PendingTraceEventRecord = TraceEventRecord & {
|
|
|
88
89
|
__reproTraceValueEntries?: TraceValueBatchEntry[];
|
|
89
90
|
};
|
|
90
91
|
type TraceValueCaptureRef = {
|
|
92
|
+
complete?: boolean;
|
|
93
|
+
omissions?: CaptureOmission[];
|
|
91
94
|
status: 'stored' | 'truncated-for-storage' | 'skipped-non-meaningful' | 'failed-policy';
|
|
92
95
|
valueId?: string;
|
|
93
96
|
projectedKind?: string;
|
|
@@ -107,6 +110,7 @@ type TraceValueBatchEntry = {
|
|
|
107
110
|
eventIndex?: number;
|
|
108
111
|
};
|
|
109
112
|
type TraceMaterializationConfig = {
|
|
113
|
+
captureMode?: 'live' | 'staging';
|
|
110
114
|
tenantId: string;
|
|
111
115
|
appId: string;
|
|
112
116
|
appSecret?: string;
|
|
@@ -284,6 +288,7 @@ type NativeMongoSuppression = {
|
|
|
284
288
|
ownerStore?: Ctx;
|
|
285
289
|
};
|
|
286
290
|
type RequestCaptureScopeState = {
|
|
291
|
+
liveCapture?: boolean;
|
|
287
292
|
requestRid: string;
|
|
288
293
|
transport: 'http' | 'kafka' | 'amqp' | 'background';
|
|
289
294
|
backendRequestCount: number;
|
|
@@ -342,6 +347,8 @@ declare function getEventTraceValueEntries(event: TraceEventRecord | PendingTrac
|
|
|
342
347
|
declare function estimateTraceBatchSerializedBytes(batch: TraceEventRecord[], requestRid: string, actionId: string | null | undefined, batchIndex: number, previouslyEmittedValueIds?: ReadonlySet<string>): number;
|
|
343
348
|
declare function chunkTraceEventsForTransport(events: TraceEventRecord[], requestRid: string, actionId: string | null | undefined): TraceEventRecord[][];
|
|
344
349
|
export type ReproMiddlewareConfig = IngestClientConfig & {
|
|
350
|
+
/** Propagated recording mode. A tagged staging request never inherits live limits. */
|
|
351
|
+
captureMode?: 'live' | 'staging';
|
|
345
352
|
/** API base used for backend-owned live session documents. Defaults to REPRO_API_BASE_URL/REPRO_API_BASE/privacy.apiBase. */
|
|
346
353
|
apiBase?: string;
|
|
347
354
|
/** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
|
|
@@ -370,6 +377,15 @@ export type ReproLiveActorDecision = ReproLiveActorArgs & {
|
|
|
370
377
|
actorLabels?: Record<string, string>;
|
|
371
378
|
};
|
|
372
379
|
export type ReproLiveModeConfig = {
|
|
380
|
+
/** Stable chain sampling. Runtime updates apply to new chains; targets are evaluated on each claim. */
|
|
381
|
+
liveCapture?: {
|
|
382
|
+
sampleRate?: number;
|
|
383
|
+
targets?: {
|
|
384
|
+
actorIds?: string[];
|
|
385
|
+
labels?: Record<string, string>;
|
|
386
|
+
expiresAt: number;
|
|
387
|
+
}[];
|
|
388
|
+
};
|
|
373
389
|
enabled?: boolean;
|
|
374
390
|
backendOnly?: boolean;
|
|
375
391
|
/**
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const url_1 = require("url");
|
|
|
10
10
|
const client_1 = require("./ingest/client");
|
|
11
11
|
const privacy_1 = require("./privacy");
|
|
12
12
|
const semantic_serialization_1 = require("./semantic-serialization");
|
|
13
|
+
const live_capture_limits_1 = require("./live-capture-limits");
|
|
13
14
|
/**
|
|
14
15
|
* ============================= IMPORTANT =============================
|
|
15
16
|
* We snapshot Mongoose core methods BEFORE tracer init, then re-apply them
|
|
@@ -828,6 +829,7 @@ function createRequestCaptureScope(requestRid, transport, cfg) {
|
|
|
828
829
|
return {
|
|
829
830
|
requestRid,
|
|
830
831
|
transport,
|
|
832
|
+
liveCapture: cfg?.captureMode === 'live' || (!!cfg && cfg.captureMode !== 'staging' && !!normalizeLiveModeConfig(cfg)),
|
|
831
833
|
// Every scope represents one captured transport request. This is an
|
|
832
834
|
// observed expectation, not a count of successful enqueue operations.
|
|
833
835
|
backendRequestCount: 1,
|
|
@@ -2054,6 +2056,7 @@ async function resolveBackendLiveContext(cfg, req, requestStart) {
|
|
|
2054
2056
|
}
|
|
2055
2057
|
const { actorId, actorType, actorLabels } = identity;
|
|
2056
2058
|
const claim = await postLiveApiJson(cfg, liveMode, '/v1/live-sessions/claim', {
|
|
2059
|
+
liveCapture: liveMode.liveCapture,
|
|
2057
2060
|
clientTime: requestStart,
|
|
2058
2061
|
captureMode: 'backend-only',
|
|
2059
2062
|
actorId,
|
|
@@ -2156,6 +2159,7 @@ function stripReproQueryFromUrl(rawUrl) {
|
|
|
2156
2159
|
parsed.searchParams.delete(REPRO_QUERY_SESSION_PARAM);
|
|
2157
2160
|
parsed.searchParams.delete(REPRO_QUERY_ACTION_PARAM);
|
|
2158
2161
|
parsed.searchParams.delete(REPRO_QUERY_START_PARAM);
|
|
2162
|
+
parsed.searchParams.delete('__repro_mode');
|
|
2159
2163
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
2160
2164
|
}
|
|
2161
2165
|
catch {
|
|
@@ -2169,6 +2173,7 @@ function stripReproQueryParams(req) {
|
|
|
2169
2173
|
delete query[REPRO_QUERY_SESSION_PARAM];
|
|
2170
2174
|
delete query[REPRO_QUERY_ACTION_PARAM];
|
|
2171
2175
|
delete query[REPRO_QUERY_START_PARAM];
|
|
2176
|
+
delete query.__repro_mode;
|
|
2172
2177
|
}
|
|
2173
2178
|
}
|
|
2174
2179
|
catch { }
|
|
@@ -2834,6 +2839,11 @@ function internTraceObservationSnapshot(snapshot, captureScope) {
|
|
|
2834
2839
|
}
|
|
2835
2840
|
function snapshotTraceValueAtObservation(value, captureScope, valuePath = '$', target = 'trace.value') {
|
|
2836
2841
|
try {
|
|
2842
|
+
if (captureScope?.liveCapture) {
|
|
2843
|
+
value = target === 'trace.args' && Array.isArray(value)
|
|
2844
|
+
? (0, live_capture_limits_1.limitLiveArguments)(value)
|
|
2845
|
+
: (0, live_capture_limits_1.limitLiveCapture)(value, valuePath).value;
|
|
2846
|
+
}
|
|
2837
2847
|
const snapshot = sanitizeTraceValue(value, 0, new WeakMap(), { preserveLongStrings: true, disableTruncation: true }, valuePath);
|
|
2838
2848
|
return internTraceObservationSnapshot(snapshot, captureScope);
|
|
2839
2849
|
}
|
|
@@ -3018,6 +3028,7 @@ async function materializePendingTraceEventsAsync(events, params) {
|
|
|
3018
3028
|
appSecret: params.cfg.appSecret,
|
|
3019
3029
|
appName: params.cfg.appName,
|
|
3020
3030
|
privacy: params.cfg.privacy,
|
|
3031
|
+
captureMode: params.captureScope?.liveCapture ? 'live' : params.cfg.captureMode,
|
|
3021
3032
|
},
|
|
3022
3033
|
maskReq: params.maskReq,
|
|
3023
3034
|
masking: params.masking,
|
|
@@ -3439,7 +3450,20 @@ async function initializeRuntimePrivacyPolicyAtStartup(cfg) {
|
|
|
3439
3450
|
}
|
|
3440
3451
|
throw new Error(`Runtime privacy policy failed to load during startup from ${apiBase} for env ${state.environment} (${lastFailure}).`);
|
|
3441
3452
|
}
|
|
3453
|
+
function isLiveCapture(cfg, scope) {
|
|
3454
|
+
if (scope)
|
|
3455
|
+
return scope.liveCapture === true;
|
|
3456
|
+
return cfg.captureMode === 'live' || (cfg.captureMode !== 'staging' && getCtx().captureScope?.liveCapture === true);
|
|
3457
|
+
}
|
|
3458
|
+
function constrainLiveCapture(value, cfg, target, scope) {
|
|
3459
|
+
if (!isLiveCapture(cfg, scope))
|
|
3460
|
+
return value;
|
|
3461
|
+
return target === 'trace.args' && Array.isArray(value)
|
|
3462
|
+
? (0, live_capture_limits_1.limitLiveArguments)(value)
|
|
3463
|
+
: (0, live_capture_limits_1.limitLiveCapture)(value).value;
|
|
3464
|
+
}
|
|
3442
3465
|
function applyPrivacyThenMask(target, value, cfg, req, trace, masking, privacy, db) {
|
|
3466
|
+
value = constrainLiveCapture(value, cfg, target);
|
|
3443
3467
|
if (shouldCapturePrivacyRaw(cfg)) {
|
|
3444
3468
|
return value;
|
|
3445
3469
|
}
|
|
@@ -3460,6 +3484,7 @@ function applyPrivacyThenMask(target, value, cfg, req, trace, masking, privacy,
|
|
|
3460
3484
|
return applyMasking(target, transformed, req, trace, masking);
|
|
3461
3485
|
}
|
|
3462
3486
|
async function applyPrivacyThenMaskAsync(target, value, cfg, req, trace, masking, privacy, db) {
|
|
3487
|
+
value = constrainLiveCapture(value, cfg, target);
|
|
3463
3488
|
if (shouldCapturePrivacyRaw(cfg)) {
|
|
3464
3489
|
return value;
|
|
3465
3490
|
}
|
|
@@ -3506,6 +3531,7 @@ function limitRawInlinePrivacyValue(target, value) {
|
|
|
3506
3531
|
return limited.skipped ? limited : null;
|
|
3507
3532
|
}
|
|
3508
3533
|
function materializeInlinePrivacyValue(target, value, cfg, req, trace, masking, privacy, db, captureScope) {
|
|
3534
|
+
value = constrainLiveCapture(value, cfg, target, captureScope);
|
|
3509
3535
|
if (value === undefined) {
|
|
3510
3536
|
return { value };
|
|
3511
3537
|
}
|
|
@@ -3529,6 +3555,7 @@ function materializeInlinePrivacyValue(target, value, cfg, req, trace, masking,
|
|
|
3529
3555
|
}
|
|
3530
3556
|
}
|
|
3531
3557
|
async function materializeInlinePrivacyValueAsync(target, value, cfg, req, trace, masking, privacy, db, captureScope) {
|
|
3558
|
+
value = constrainLiveCapture(value, cfg, target, captureScope);
|
|
3532
3559
|
if (value === undefined) {
|
|
3533
3560
|
return { value };
|
|
3534
3561
|
}
|
|
@@ -4505,6 +4532,8 @@ function boundTraceEventsForTransport(events) {
|
|
|
4505
4532
|
function createCapturedValueEntry(params) {
|
|
4506
4533
|
if (!__FULL_VALUE_CAPTURE_ENABLED)
|
|
4507
4534
|
return undefined;
|
|
4535
|
+
const limitedRaw = constrainLiveCapture(params.rawValue, params.capture.runtimeConfig, params.target);
|
|
4536
|
+
const omissions = isLiveCapture(params.capture.runtimeConfig) ? (0, live_capture_limits_1.collectCaptureOmissions)(limitedRaw) : [];
|
|
4508
4537
|
const preferredLimits = {
|
|
4509
4538
|
maxDepth: TRACE_FULL_VALUE_MAX_DEPTH,
|
|
4510
4539
|
maxKeys: TRACE_FULL_VALUE_MAX_KEYS,
|
|
@@ -4514,7 +4543,7 @@ function createCapturedValueEntry(params) {
|
|
|
4514
4543
|
const buildMaskedCandidate = (limits) => {
|
|
4515
4544
|
const projected = params.target === 'request.headers'
|
|
4516
4545
|
? sanitizeHeaders(params.rawValue, params.capture.captureHeaders)
|
|
4517
|
-
: cloneFullValueCandidate(
|
|
4546
|
+
: cloneFullValueCandidate(limitedRaw, params.capture, limits);
|
|
4518
4547
|
if (projected === undefined)
|
|
4519
4548
|
return undefined;
|
|
4520
4549
|
return applyPrivacyThenMask(params.target, projected, params.capture.runtimeConfig, params.capture.maskReq, params.capture.trace, params.capture.masking, params.capture.privacy, params.capture.db);
|
|
@@ -4525,7 +4554,7 @@ function createCapturedValueEntry(params) {
|
|
|
4525
4554
|
return { captureRef: { status: 'skipped-non-meaningful' } };
|
|
4526
4555
|
}
|
|
4527
4556
|
const candidateSerialized = stableSerializeTraceValue(candidate);
|
|
4528
|
-
if (!candidateSerialized || candidateSerialized === previewSerialized) {
|
|
4557
|
+
if (!candidateSerialized || (candidateSerialized === previewSerialized && !omissions.length)) {
|
|
4529
4558
|
return undefined;
|
|
4530
4559
|
}
|
|
4531
4560
|
const valueId = contentAddressedValueId(params.target, candidateSerialized, params.capture);
|
|
@@ -4545,6 +4574,7 @@ function createCapturedValueEntry(params) {
|
|
|
4545
4574
|
status: 'stored',
|
|
4546
4575
|
valueId,
|
|
4547
4576
|
projectedKind: entry.projectedKind,
|
|
4577
|
+
...(omissions.length ? { complete: false, omissions } : {}),
|
|
4548
4578
|
},
|
|
4549
4579
|
entry,
|
|
4550
4580
|
};
|
|
@@ -4552,6 +4582,8 @@ function createCapturedValueEntry(params) {
|
|
|
4552
4582
|
async function createCapturedValueEntryAsync(params) {
|
|
4553
4583
|
if (!__FULL_VALUE_CAPTURE_ENABLED)
|
|
4554
4584
|
return undefined;
|
|
4585
|
+
const limitedRaw = constrainLiveCapture(params.rawValue, params.capture.runtimeConfig, params.target);
|
|
4586
|
+
const omissions = isLiveCapture(params.capture.runtimeConfig) ? (0, live_capture_limits_1.collectCaptureOmissions)(limitedRaw) : [];
|
|
4555
4587
|
const preferredLimits = {
|
|
4556
4588
|
maxDepth: TRACE_FULL_VALUE_MAX_DEPTH,
|
|
4557
4589
|
maxKeys: TRACE_FULL_VALUE_MAX_KEYS,
|
|
@@ -4561,7 +4593,7 @@ async function createCapturedValueEntryAsync(params) {
|
|
|
4561
4593
|
const buildMaskedCandidate = async (limits) => {
|
|
4562
4594
|
const projected = params.target === 'request.headers'
|
|
4563
4595
|
? sanitizeHeaders(params.rawValue, params.capture.captureHeaders)
|
|
4564
|
-
: await cloneFullValueCandidateAsync(
|
|
4596
|
+
: await cloneFullValueCandidateAsync(limitedRaw, params.capture, limits);
|
|
4565
4597
|
if (projected === undefined)
|
|
4566
4598
|
return undefined;
|
|
4567
4599
|
return applyPrivacyThenMaskAsync(params.target, projected, params.capture.runtimeConfig, params.capture.maskReq, params.capture.trace, params.capture.masking, params.capture.privacy, params.capture.db);
|
|
@@ -4572,7 +4604,7 @@ async function createCapturedValueEntryAsync(params) {
|
|
|
4572
4604
|
return { captureRef: { status: 'skipped-non-meaningful' } };
|
|
4573
4605
|
}
|
|
4574
4606
|
const candidateSerialized = stableSerializeTraceValue(candidate);
|
|
4575
|
-
if (!candidateSerialized || candidateSerialized === previewSerialized) {
|
|
4607
|
+
if (!candidateSerialized || (candidateSerialized === previewSerialized && !omissions.length)) {
|
|
4576
4608
|
return undefined;
|
|
4577
4609
|
}
|
|
4578
4610
|
const valueId = contentAddressedValueId(params.target, candidateSerialized, params.capture);
|
|
@@ -4592,6 +4624,7 @@ async function createCapturedValueEntryAsync(params) {
|
|
|
4592
4624
|
status: 'stored',
|
|
4593
4625
|
valueId,
|
|
4594
4626
|
projectedKind: entry.projectedKind,
|
|
4627
|
+
...(omissions.length ? { complete: false, omissions } : {}),
|
|
4595
4628
|
},
|
|
4596
4629
|
entry,
|
|
4597
4630
|
};
|
|
@@ -4794,6 +4827,7 @@ function reproMiddleware(cfg) {
|
|
|
4794
4827
|
const querySid = readRequestQueryParam(req, REPRO_QUERY_SESSION_PARAM);
|
|
4795
4828
|
const queryAid = readRequestQueryParam(req, REPRO_QUERY_ACTION_PARAM);
|
|
4796
4829
|
const queryTs = readRequestQueryNumber(req, REPRO_QUERY_START_PARAM);
|
|
4830
|
+
const queryCaptureMode = readRequestQueryParam(req, '__repro_mode');
|
|
4797
4831
|
if (querySid || queryAid || queryTs !== null) {
|
|
4798
4832
|
stripReproQueryParams(req);
|
|
4799
4833
|
}
|
|
@@ -4863,6 +4897,11 @@ function reproMiddleware(cfg) {
|
|
|
4863
4897
|
}],
|
|
4864
4898
|
}, { priority: 'high' });
|
|
4865
4899
|
}
|
|
4900
|
+
const captureMode = backendLiveContext || req.headers['x-repro-capture-mode'] === 'live' || queryCaptureMode === 'live'
|
|
4901
|
+
? 'live' : 'staging';
|
|
4902
|
+
const modeConfig = { ...captureCfg, captureMode };
|
|
4903
|
+
shareRuntimePrivacyState(captureCfg, modeConfig);
|
|
4904
|
+
captureCfg = modeConfig;
|
|
4866
4905
|
const resolvePrivacy = () => getRuntimePrivacyState(captureCfg).policy ?? null;
|
|
4867
4906
|
const clockSkewMs = headerTs !== null ? headerTs - requestStartRaw : 0;
|
|
4868
4907
|
const rid = nextRequestId(requestEpochMs);
|
|
@@ -6287,6 +6326,8 @@ function patchNodeFetchForPropagation() {
|
|
|
6287
6326
|
if (runtime.aid && !hasHttpHeader(headers, KAFKA_HEADER_ACTION_ID)) {
|
|
6288
6327
|
setHttpHeader(headers, KAFKA_HEADER_ACTION_ID, runtime.aid);
|
|
6289
6328
|
}
|
|
6329
|
+
if (runtime.sid)
|
|
6330
|
+
setHttpHeader(headers, 'x-repro-capture-mode', getCtx().captureScope?.liveCapture ? 'live' : 'staging');
|
|
6290
6331
|
if (runtime.traceId && !hasHttpHeader(headers, KAFKA_HEADER_TRACE_ID)) {
|
|
6291
6332
|
setHttpHeader(headers, KAFKA_HEADER_TRACE_ID, runtime.traceId);
|
|
6292
6333
|
}
|
|
@@ -6337,6 +6378,8 @@ function patchNodeHttpModuleRequest(mod) {
|
|
|
6337
6378
|
maybeSet(REQUEST_START_HEADER, String(alignedNow()));
|
|
6338
6379
|
maybeSet(KAFKA_HEADER_SESSION_ID, runtime.sid);
|
|
6339
6380
|
maybeSet(KAFKA_HEADER_ACTION_ID, runtime.aid);
|
|
6381
|
+
if (runtime.sid)
|
|
6382
|
+
maybeSet('x-repro-capture-mode', getCtx().captureScope?.liveCapture ? 'live' : 'staging');
|
|
6340
6383
|
maybeSet(KAFKA_HEADER_TRACE_ID, runtime.traceId);
|
|
6341
6384
|
maybeSet(KAFKA_HEADER_PARENT_SPAN_ID, runtime.parentSpanId);
|
|
6342
6385
|
maybeSet(KAFKA_HEADER_REQUEST_ID, runtime.requestRid);
|
|
@@ -6513,6 +6556,11 @@ function cloneKafkaTelemetryValue(value) {
|
|
|
6513
6556
|
}
|
|
6514
6557
|
}
|
|
6515
6558
|
async function materializeKafkaRequestBody(cfg, maskReq, body, captureScope) {
|
|
6559
|
+
// Broker bodies have no separate full-value reference. Returning the trace
|
|
6560
|
+
// inline preview here would permanently replace message arrays with 20 items.
|
|
6561
|
+
if (shouldCapturePrivacyRaw(cfg)) {
|
|
6562
|
+
return { value: constrainLiveCapture(sanitizeInlinePrivacyValue(body), cfg, 'request.body', captureScope) };
|
|
6563
|
+
}
|
|
6516
6564
|
return materializeInlinePrivacyValueAsync('request.body', sanitizeInlinePrivacyValue(body), cfg, maskReq, null, normalizeMaskingConfig(cfg.masking), getRuntimePrivacyState(cfg).policy ?? null, undefined, captureScope);
|
|
6517
6565
|
}
|
|
6518
6566
|
function summarizeKafkaError(error) {
|
|
@@ -6557,7 +6605,8 @@ function resolveKafkaContext(cfg, headers) {
|
|
|
6557
6605
|
readKafkaHeader(headers, KAFKA_HEADER_PARENT_SPAN_ID) ??
|
|
6558
6606
|
null;
|
|
6559
6607
|
const requestRid = readKafkaHeader(headers, KAFKA_HEADER_REQUEST_ID);
|
|
6560
|
-
return { sid, aid, traceId, parentSpanId, requestRid
|
|
6608
|
+
return { sid, aid, traceId, parentSpanId, requestRid,
|
|
6609
|
+
captureMode: readKafkaHeader(headers, 'x-repro-capture-mode') === 'live' || ctx.captureScope?.liveCapture ? 'live' : 'staging' };
|
|
6561
6610
|
}
|
|
6562
6611
|
function withOptionalTraceContext(traceId, fn) {
|
|
6563
6612
|
const tracerWithTrace = __TRACER__?.withTrace;
|
|
@@ -6857,6 +6906,7 @@ function patchKafkaProducerInstance(producer, cfg) {
|
|
|
6857
6906
|
[KAFKA_HEADER_REQUEST_ID]: publishRid,
|
|
6858
6907
|
[KAFKA_HEADER_SESSION_ID]: sid,
|
|
6859
6908
|
[KAFKA_HEADER_ACTION_ID]: aid,
|
|
6909
|
+
'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
|
|
6860
6910
|
}),
|
|
6861
6911
|
};
|
|
6862
6912
|
});
|
|
@@ -6979,6 +7029,7 @@ function patchKafkaProducerInstance(producer, cfg) {
|
|
|
6979
7029
|
[KAFKA_HEADER_REQUEST_ID]: publishRid,
|
|
6980
7030
|
[KAFKA_HEADER_SESSION_ID]: sid,
|
|
6981
7031
|
[KAFKA_HEADER_ACTION_ID]: aid,
|
|
7032
|
+
'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
|
|
6982
7033
|
}),
|
|
6983
7034
|
};
|
|
6984
7035
|
}),
|
|
@@ -7091,6 +7142,7 @@ function wrapKafkaEachMessageHandler(eachMessage, cfg, consumer) {
|
|
|
7091
7142
|
const consumeRid = nextRequestId(`${topic}-${message?.offset ?? alignedNow()}`);
|
|
7092
7143
|
const traceId = consumeRid;
|
|
7093
7144
|
const captureScope = createRequestCaptureScope(consumeRid, 'kafka', cfg);
|
|
7145
|
+
captureScope.liveCapture = runtime.captureMode === 'live';
|
|
7094
7146
|
emitRequestCaptureStart(cfg, sid, aid, captureScope);
|
|
7095
7147
|
const rawTraceEvents = [];
|
|
7096
7148
|
const maskReq = {
|
|
@@ -7205,6 +7257,7 @@ function wrapKafkaEachBatchHandler(eachBatch, cfg, consumer) {
|
|
|
7205
7257
|
const consumeRid = nextRequestId(`${topic}-batch-${alignedNow()}`);
|
|
7206
7258
|
const traceId = consumeRid;
|
|
7207
7259
|
const captureScope = createRequestCaptureScope(consumeRid, 'kafka', cfg);
|
|
7260
|
+
captureScope.liveCapture = runtime.captureMode === 'live';
|
|
7208
7261
|
emitRequestCaptureStart(cfg, sid, aid, captureScope);
|
|
7209
7262
|
const rawTraceEvents = [];
|
|
7210
7263
|
const maskReq = {
|
|
@@ -7642,7 +7695,8 @@ function resolveAmqpContext(cfg, headers) {
|
|
|
7642
7695
|
readAmqpHeader(headers, KAFKA_HEADER_PARENT_SPAN_ID) ??
|
|
7643
7696
|
null;
|
|
7644
7697
|
const requestRid = readAmqpHeader(headers, KAFKA_HEADER_REQUEST_ID);
|
|
7645
|
-
return { sid, aid, traceId, parentSpanId, requestRid
|
|
7698
|
+
return { sid, aid, traceId, parentSpanId, requestRid,
|
|
7699
|
+
captureMode: readAmqpHeader(headers, 'x-repro-capture-mode') === 'live' || ctx.captureScope?.liveCapture ? 'live' : 'staging' };
|
|
7646
7700
|
}
|
|
7647
7701
|
function withOptionalTraceContextSync(traceId, fn) {
|
|
7648
7702
|
const tracerWithTrace = __TRACER__?.withTrace;
|
|
@@ -7817,6 +7871,7 @@ function patchAmqplibPublishMethod(channel, cfg, methodName) {
|
|
|
7817
7871
|
[KAFKA_HEADER_REQUEST_ID]: publishRid,
|
|
7818
7872
|
[KAFKA_HEADER_SESSION_ID]: sid,
|
|
7819
7873
|
[KAFKA_HEADER_ACTION_ID]: aid,
|
|
7874
|
+
'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
|
|
7820
7875
|
};
|
|
7821
7876
|
const shouldPropagate = hasAmqpPropagationValues(propagationValues);
|
|
7822
7877
|
const callbackIndex = args.length > 0 && typeof args[args.length - 1] === 'function'
|
|
@@ -7922,6 +7977,7 @@ function wrapAmqplibConsumeHandler(onMessage, cfg, queue) {
|
|
|
7922
7977
|
const consumeRid = nextRequestId(`${queueName}-${deliveryTag}`);
|
|
7923
7978
|
const traceId = consumeRid;
|
|
7924
7979
|
const captureScope = createRequestCaptureScope(consumeRid, 'amqp', cfg);
|
|
7980
|
+
captureScope.liveCapture = runtime.captureMode === 'live';
|
|
7925
7981
|
emitRequestCaptureStart(cfg, sid, aid, captureScope);
|
|
7926
7982
|
const rawTraceEvents = [];
|
|
7927
7983
|
const maskReq = {
|
package/dist/ingest/client.js
CHANGED
|
@@ -47,6 +47,8 @@ let backoffMs = 0;
|
|
|
47
47
|
let criticalBackoffMs = 0;
|
|
48
48
|
let shouldDeferDrain = null;
|
|
49
49
|
let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
|
|
50
|
+
const throttledUntil = new Map();
|
|
51
|
+
const throttleDelay = (config) => Math.max(0, (throttledUntil.get(resolveIngestUrl(config)) ?? 0) - Date.now());
|
|
50
52
|
const configureIngestQueueDrain = (options) => {
|
|
51
53
|
shouldDeferDrain = options.shouldDefer ?? null;
|
|
52
54
|
if (Number.isFinite(options.deferMs) && Number(options.deferMs) >= 0) {
|
|
@@ -166,6 +168,9 @@ const postIngestEntries = async (config, sessionId, entries) => {
|
|
|
166
168
|
};
|
|
167
169
|
exports.postIngestEntries = postIngestEntries;
|
|
168
170
|
const sendIngestEntries = async (config, sessionId, entries) => {
|
|
171
|
+
if (throttleDelay(config) > 0)
|
|
172
|
+
return 'throttled';
|
|
173
|
+
throttledUntil.delete(resolveIngestUrl(config));
|
|
169
174
|
if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
|
|
170
175
|
return true;
|
|
171
176
|
}
|
|
@@ -197,6 +202,18 @@ const sendIngestEntries = async (config, sessionId, entries) => {
|
|
|
197
202
|
body: JSON.stringify({ events }),
|
|
198
203
|
...(controller ? { signal: controller.signal } : {}),
|
|
199
204
|
});
|
|
205
|
+
if (response.status === 429) {
|
|
206
|
+
const retryAfter = response.headers?.get?.('retry-after');
|
|
207
|
+
const seconds = retryAfter ? Number(retryAfter) : NaN;
|
|
208
|
+
const date = retryAfter ? Date.parse(retryAfter) : NaN;
|
|
209
|
+
const delay = Number.isFinite(seconds) ? seconds * 1000 : Number.isFinite(date) ? date - Date.now() : 1000;
|
|
210
|
+
throttledUntil.set(resolveIngestUrl(config), Date.now() + Math.max(1000, Math.min(86400000, delay)));
|
|
211
|
+
return 'throttled';
|
|
212
|
+
}
|
|
213
|
+
if (response.status === 402 || response.status === 410) {
|
|
214
|
+
console.warn(`[repro] Upload refused for ${sessionId}: ${response.status === 402 ? 'capture quota exhausted' : 'capture deleted, expired or failed'}. This batch will not be retried.`);
|
|
215
|
+
return 'denied';
|
|
216
|
+
}
|
|
200
217
|
return response.ok;
|
|
201
218
|
}
|
|
202
219
|
catch {
|
|
@@ -257,7 +274,11 @@ const drainCriticalIngestQueue = async () => {
|
|
|
257
274
|
return;
|
|
258
275
|
}
|
|
259
276
|
const ok = await sendIngestEntries(selected.batch.config, selected.batch.sessionId, selected.batch.entries);
|
|
260
|
-
if (ok) {
|
|
277
|
+
if (ok === 'throttled') {
|
|
278
|
+
scheduleCriticalDrain(throttleDelay(selected.batch.config));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (ok === true || ok === 'denied') {
|
|
261
282
|
criticalIngestQueue.splice(0, selected.queueItems);
|
|
262
283
|
criticalBackoffMs = 0;
|
|
263
284
|
continue;
|
|
@@ -298,7 +319,11 @@ const drainIngestQueue = async () => {
|
|
|
298
319
|
}
|
|
299
320
|
const batch = selected.batch;
|
|
300
321
|
const ok = await sendIngestEntries(batch.config, batch.sessionId, batch.entries);
|
|
301
|
-
if (ok) {
|
|
322
|
+
if (ok === 'throttled') {
|
|
323
|
+
scheduleDrain(throttleDelay(batch.config));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (ok === true || ok === 'denied') {
|
|
302
327
|
ingestQueue.splice(0, selected.queueItems);
|
|
303
328
|
backoffMs = 0;
|
|
304
329
|
continue;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type CaptureOmission = {
|
|
2
|
+
reason: 'max-depth' | 'max-keys' | 'max-entries' | 'accessor' | 'uninspectable';
|
|
3
|
+
path: string;
|
|
4
|
+
limit?: number;
|
|
5
|
+
originalCount?: number;
|
|
6
|
+
capturedCount: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const LIVE_CAPTURE_LIMITS: Readonly<{
|
|
9
|
+
maxDepth: 10;
|
|
10
|
+
maxKeys: 100;
|
|
11
|
+
maxEntries: 100;
|
|
12
|
+
}>;
|
|
13
|
+
/** Snapshot only data descriptors: capture must never execute application getters. */
|
|
14
|
+
export declare function limitLiveCapture(value: unknown, rootPath?: string): {
|
|
15
|
+
value: any;
|
|
16
|
+
omissions: CaptureOmission[];
|
|
17
|
+
};
|
|
18
|
+
export declare function collectCaptureOmissions(value: any): CaptureOmission[];
|
|
19
|
+
export declare function limitLiveArguments(args: any[]): any[];
|