@reproapp/node-sdk 0.0.49 → 0.0.51
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 +83 -31
- package/dist/ingest/client.d.ts +2 -2
- package/dist/ingest/client.js +33 -7
- 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 +7 -2
- package/src/index.ts +103 -31
- package/src/ingest/client.ts +36 -10
- package/src/live-capture-limits.ts +177 -0
- package/src/semantic-serialization.ts +30 -0
- package/test/background-continuous-traffic.testcontainers.test.js +100 -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/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type RuntimePrivacyTraceContext,
|
|
30
30
|
} from './privacy';
|
|
31
31
|
import { projectSemanticInfrastructureValue } from './semantic-serialization';
|
|
32
|
+
import { limitLiveCapture, limitLiveArguments, collectCaptureOmissions, type CaptureOmission } from './live-capture-limits';
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* ============================= IMPORTANT =============================
|
|
@@ -380,6 +381,8 @@ type PendingTraceEventRecord = TraceEventRecord & {
|
|
|
380
381
|
};
|
|
381
382
|
|
|
382
383
|
type TraceValueCaptureRef = {
|
|
384
|
+
complete?: boolean;
|
|
385
|
+
omissions?: CaptureOmission[];
|
|
383
386
|
status:
|
|
384
387
|
| 'stored'
|
|
385
388
|
| 'truncated-for-storage'
|
|
@@ -419,6 +422,7 @@ type TraceValueBatchEntry = {
|
|
|
419
422
|
};
|
|
420
423
|
|
|
421
424
|
type TraceMaterializationConfig = {
|
|
425
|
+
captureMode?: 'live' | 'staging';
|
|
422
426
|
tenantId: string;
|
|
423
427
|
appId: string;
|
|
424
428
|
appSecret?: string;
|
|
@@ -1213,6 +1217,7 @@ type NativeMongoSuppression = {
|
|
|
1213
1217
|
};
|
|
1214
1218
|
|
|
1215
1219
|
type RequestCaptureScopeState = {
|
|
1220
|
+
liveCapture?: boolean;
|
|
1216
1221
|
requestRid: string;
|
|
1217
1222
|
transport: 'http' | 'kafka' | 'amqp' | 'background';
|
|
1218
1223
|
backendRequestCount: number;
|
|
@@ -1246,6 +1251,7 @@ function createRequestCaptureScope(
|
|
|
1246
1251
|
return {
|
|
1247
1252
|
requestRid,
|
|
1248
1253
|
transport,
|
|
1254
|
+
liveCapture: cfg?.captureMode === 'live' || (!!cfg && cfg.captureMode !== 'staging' && !!normalizeLiveModeConfig(cfg)),
|
|
1249
1255
|
// Every scope represents one captured transport request. This is an
|
|
1250
1256
|
// observed expectation, not a count of successful enqueue operations.
|
|
1251
1257
|
backendRequestCount: 1,
|
|
@@ -1546,8 +1552,9 @@ let activeClientRequestCount = 0;
|
|
|
1546
1552
|
let oldestActiveClientRequestAt: number | null = null;
|
|
1547
1553
|
let lastClientActivityAt = 0;
|
|
1548
1554
|
let sdkBackgroundQuietUntil = 0;
|
|
1549
|
-
|
|
1550
|
-
const
|
|
1555
|
+
type SdkBackgroundWork = { work: () => Promise<void> | void; enqueuedAt: number };
|
|
1556
|
+
const sdkBackgroundHighQueue: SdkBackgroundWork[] = [];
|
|
1557
|
+
const sdkBackgroundQueue: SdkBackgroundWork[] = [];
|
|
1551
1558
|
let sdkBackgroundTimer: NodeJS.Timeout | null = null;
|
|
1552
1559
|
let sdkBackgroundDraining = false;
|
|
1553
1560
|
let traceMaterializerWorker: any = null;
|
|
@@ -1569,23 +1576,25 @@ function noteClientActivity(now = Date.now()): void {
|
|
|
1569
1576
|
lastClientActivityAt = now;
|
|
1570
1577
|
}
|
|
1571
1578
|
|
|
1572
|
-
function getSdkBackgroundDelayMs(now = Date.now()): number {
|
|
1579
|
+
function getSdkBackgroundDelayMs(now = Date.now(), ingestEnqueuedAt?: number): number {
|
|
1580
|
+
// Bound deferral by work age, not just by a continuously active request.
|
|
1581
|
+
// Short non-overlapping requests reset oldestActiveClientRequestAt forever.
|
|
1582
|
+
const oldestWorkAt = Math.min(
|
|
1583
|
+
sdkBackgroundHighQueue[0]?.enqueuedAt ?? Infinity,
|
|
1584
|
+
sdkBackgroundQueue[0]?.enqueuedAt ?? Infinity,
|
|
1585
|
+
ingestEnqueuedAt ?? Infinity,
|
|
1586
|
+
oldestActiveClientRequestAt ?? Infinity,
|
|
1587
|
+
);
|
|
1588
|
+
const remainingDeferMs = Number.isFinite(oldestWorkAt) && Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS)
|
|
1589
|
+
? Math.max(0, oldestWorkAt + SDK_BACKGROUND_MAX_DEFER_MS - now)
|
|
1590
|
+
: Infinity;
|
|
1573
1591
|
if (hasActiveClientRequests()) {
|
|
1574
|
-
|
|
1575
|
-
oldestActiveClientRequestAt !== null &&
|
|
1576
|
-
Number.isFinite(SDK_BACKGROUND_MAX_DEFER_MS) &&
|
|
1577
|
-
SDK_BACKGROUND_MAX_DEFER_MS >= 0
|
|
1578
|
-
) {
|
|
1579
|
-
const activeForMs = now - oldestActiveClientRequestAt;
|
|
1580
|
-
if (activeForMs >= SDK_BACKGROUND_MAX_DEFER_MS) return 0;
|
|
1581
|
-
return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, Math.max(0, SDK_BACKGROUND_MAX_DEFER_MS - activeForMs));
|
|
1582
|
-
}
|
|
1583
|
-
return SDK_BACKGROUND_IDLE_DELAY_MS;
|
|
1592
|
+
return Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, remainingDeferMs);
|
|
1584
1593
|
}
|
|
1585
1594
|
const sinceLastActivity = now - lastClientActivityAt;
|
|
1586
1595
|
const idleDelayRemaining = Math.max(0, SDK_BACKGROUND_IDLE_DELAY_MS - sinceLastActivity);
|
|
1587
1596
|
const quietWindowRemaining = Math.max(0, sdkBackgroundQuietUntil - now);
|
|
1588
|
-
return Math.max(idleDelayRemaining, quietWindowRemaining);
|
|
1597
|
+
return Math.min(Math.max(idleDelayRemaining, quietWindowRemaining), remainingDeferMs);
|
|
1589
1598
|
}
|
|
1590
1599
|
|
|
1591
1600
|
function shouldDeferSdkBackgroundWork(now = Date.now()): boolean {
|
|
@@ -1798,10 +1807,8 @@ function beginClientRequest(): void {
|
|
|
1798
1807
|
oldestActiveClientRequestAt = now;
|
|
1799
1808
|
}
|
|
1800
1809
|
activeClientRequestCount += 1;
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
sdkBackgroundTimer = null;
|
|
1804
|
-
}
|
|
1810
|
+
// The scheduled drain rechecks activity; cancelling it here loses the
|
|
1811
|
+
// queued work's deadline under sustained traffic.
|
|
1805
1812
|
}
|
|
1806
1813
|
|
|
1807
1814
|
function endClientRequest(): void {
|
|
@@ -1822,15 +1829,17 @@ function scheduleSdkBackgroundWork(
|
|
|
1822
1829
|
work: () => Promise<void> | void,
|
|
1823
1830
|
options: { priority?: 'high' | 'normal' } = {},
|
|
1824
1831
|
): void {
|
|
1832
|
+
const item = { work, enqueuedAt: Date.now() };
|
|
1825
1833
|
if (options.priority === 'high') {
|
|
1826
|
-
sdkBackgroundHighQueue.push(
|
|
1834
|
+
sdkBackgroundHighQueue.push(item);
|
|
1827
1835
|
} else {
|
|
1828
|
-
sdkBackgroundQueue.push(
|
|
1836
|
+
sdkBackgroundQueue.push(item);
|
|
1829
1837
|
}
|
|
1830
1838
|
scheduleSdkBackgroundDrain();
|
|
1831
1839
|
}
|
|
1832
1840
|
|
|
1833
1841
|
function scheduleSdkBackgroundDrain(delayMs = 0): void {
|
|
1842
|
+
if (!sdkBackgroundHighQueue.length && !sdkBackgroundQueue.length) return;
|
|
1834
1843
|
if (sdkBackgroundTimer) return;
|
|
1835
1844
|
const nextDelay = Math.max(delayMs, getSdkBackgroundDelayMs());
|
|
1836
1845
|
sdkBackgroundTimer = setTimeout(() => {
|
|
@@ -1865,11 +1874,11 @@ async function drainSdkBackgroundQueue(): Promise<void> {
|
|
|
1865
1874
|
scheduleSdkBackgroundDrain();
|
|
1866
1875
|
return;
|
|
1867
1876
|
}
|
|
1868
|
-
const
|
|
1869
|
-
if (!
|
|
1877
|
+
const item = sdkBackgroundHighQueue.shift() ?? sdkBackgroundQueue.shift();
|
|
1878
|
+
if (!item) continue;
|
|
1870
1879
|
try {
|
|
1871
1880
|
await drainQueuedIngestBeforeBackgroundWork();
|
|
1872
|
-
await runSdkBackgroundWork(work);
|
|
1881
|
+
await runSdkBackgroundWork(item.work);
|
|
1873
1882
|
} catch {
|
|
1874
1883
|
// never break user code
|
|
1875
1884
|
}
|
|
@@ -1885,8 +1894,8 @@ async function drainSdkBackgroundQueue(): Promise<void> {
|
|
|
1885
1894
|
}
|
|
1886
1895
|
|
|
1887
1896
|
configureIngestQueueDrain({
|
|
1888
|
-
shouldDefer:
|
|
1889
|
-
deferMs: SDK_BACKGROUND_IDLE_DELAY_MS,
|
|
1897
|
+
shouldDefer: (enqueuedAt) => getSdkBackgroundDelayMs(Date.now(), enqueuedAt) > 0,
|
|
1898
|
+
deferMs: Math.min(SDK_BACKGROUND_IDLE_DELAY_MS, SDK_BACKGROUND_MAX_DEFER_MS),
|
|
1890
1899
|
});
|
|
1891
1900
|
|
|
1892
1901
|
// Track in-flight requests per session so we can delay flushing until a session is drained.
|
|
@@ -2599,6 +2608,7 @@ async function resolveBackendLiveContext(
|
|
|
2599
2608
|
liveMode,
|
|
2600
2609
|
'/v1/live-sessions/claim',
|
|
2601
2610
|
{
|
|
2611
|
+
liveCapture: liveMode.liveCapture,
|
|
2602
2612
|
clientTime: requestStart,
|
|
2603
2613
|
captureMode: 'backend-only',
|
|
2604
2614
|
actorId,
|
|
@@ -2712,6 +2722,7 @@ function stripReproQueryFromUrl(rawUrl: string): string {
|
|
|
2712
2722
|
parsed.searchParams.delete(REPRO_QUERY_SESSION_PARAM);
|
|
2713
2723
|
parsed.searchParams.delete(REPRO_QUERY_ACTION_PARAM);
|
|
2714
2724
|
parsed.searchParams.delete(REPRO_QUERY_START_PARAM);
|
|
2725
|
+
parsed.searchParams.delete('__repro_mode');
|
|
2715
2726
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
2716
2727
|
} catch {
|
|
2717
2728
|
return rawUrl;
|
|
@@ -2725,6 +2736,7 @@ function stripReproQueryParams(req: Request): void {
|
|
|
2725
2736
|
delete query[REPRO_QUERY_SESSION_PARAM];
|
|
2726
2737
|
delete query[REPRO_QUERY_ACTION_PARAM];
|
|
2727
2738
|
delete query[REPRO_QUERY_START_PARAM];
|
|
2739
|
+
delete query.__repro_mode;
|
|
2728
2740
|
}
|
|
2729
2741
|
} catch {}
|
|
2730
2742
|
try {
|
|
@@ -3439,6 +3451,11 @@ function snapshotTraceValueAtObservation(
|
|
|
3439
3451
|
target = 'trace.value',
|
|
3440
3452
|
): any {
|
|
3441
3453
|
try {
|
|
3454
|
+
if (captureScope?.liveCapture) {
|
|
3455
|
+
value = target === 'trace.args' && Array.isArray(value)
|
|
3456
|
+
? limitLiveArguments(value)
|
|
3457
|
+
: limitLiveCapture(value, valuePath).value;
|
|
3458
|
+
}
|
|
3442
3459
|
const snapshot = sanitizeTraceValue(
|
|
3443
3460
|
value,
|
|
3444
3461
|
0,
|
|
@@ -3714,6 +3731,7 @@ async function materializePendingTraceEventsAsync(
|
|
|
3714
3731
|
appSecret: params.cfg.appSecret,
|
|
3715
3732
|
appName: params.cfg.appName,
|
|
3716
3733
|
privacy: params.cfg.privacy,
|
|
3734
|
+
captureMode: params.captureScope?.liveCapture ? 'live' : params.cfg.captureMode,
|
|
3717
3735
|
},
|
|
3718
3736
|
maskReq: params.maskReq,
|
|
3719
3737
|
masking: params.masking,
|
|
@@ -4223,6 +4241,18 @@ async function initializeRuntimePrivacyPolicyAtStartup(cfg: ReproMiddlewareConfi
|
|
|
4223
4241
|
);
|
|
4224
4242
|
}
|
|
4225
4243
|
|
|
4244
|
+
function isLiveCapture(cfg: ReproMiddlewareConfig, scope?: RequestCaptureScopeState | null): boolean {
|
|
4245
|
+
if (scope) return scope.liveCapture === true;
|
|
4246
|
+
return cfg.captureMode === 'live' || (cfg.captureMode !== 'staging' && getCtx().captureScope?.liveCapture === true);
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4249
|
+
function constrainLiveCapture(value: any, cfg: ReproMiddlewareConfig, target: string, scope?: RequestCaptureScopeState | null): any {
|
|
4250
|
+
if (!isLiveCapture(cfg, scope)) return value;
|
|
4251
|
+
return target === 'trace.args' && Array.isArray(value)
|
|
4252
|
+
? limitLiveArguments(value)
|
|
4253
|
+
: limitLiveCapture(value).value;
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4226
4256
|
function applyPrivacyThenMask(
|
|
4227
4257
|
target: RuntimePrivacySurface,
|
|
4228
4258
|
value: any,
|
|
@@ -4233,6 +4263,7 @@ function applyPrivacyThenMask(
|
|
|
4233
4263
|
privacy: NormalizedRuntimePrivacyPolicy | null,
|
|
4234
4264
|
db?: RuntimePrivacyDbContext | null,
|
|
4235
4265
|
) {
|
|
4266
|
+
value = constrainLiveCapture(value, cfg, target);
|
|
4236
4267
|
if (shouldCapturePrivacyRaw(cfg)) {
|
|
4237
4268
|
return value;
|
|
4238
4269
|
}
|
|
@@ -4263,6 +4294,7 @@ async function applyPrivacyThenMaskAsync(
|
|
|
4263
4294
|
privacy: NormalizedRuntimePrivacyPolicy | null,
|
|
4264
4295
|
db?: RuntimePrivacyDbContext | null,
|
|
4265
4296
|
) {
|
|
4297
|
+
value = constrainLiveCapture(value, cfg, target);
|
|
4266
4298
|
if (shouldCapturePrivacyRaw(cfg)) {
|
|
4267
4299
|
return value;
|
|
4268
4300
|
}
|
|
@@ -4342,6 +4374,7 @@ function materializeInlinePrivacyValue(
|
|
|
4342
4374
|
db?: RuntimePrivacyDbContext | null,
|
|
4343
4375
|
captureScope?: RequestCaptureScopeState | null,
|
|
4344
4376
|
): InlinePrivacyMaterializationResult {
|
|
4377
|
+
value = constrainLiveCapture(value, cfg, target, captureScope);
|
|
4345
4378
|
if (value === undefined) {
|
|
4346
4379
|
return { value };
|
|
4347
4380
|
}
|
|
@@ -4381,6 +4414,7 @@ async function materializeInlinePrivacyValueAsync(
|
|
|
4381
4414
|
db?: RuntimePrivacyDbContext | null,
|
|
4382
4415
|
captureScope?: RequestCaptureScopeState | null,
|
|
4383
4416
|
): Promise<InlinePrivacyMaterializationResult> {
|
|
4417
|
+
value = constrainLiveCapture(value, cfg, target, captureScope);
|
|
4384
4418
|
if (value === undefined) {
|
|
4385
4419
|
return { value };
|
|
4386
4420
|
}
|
|
@@ -5549,6 +5583,9 @@ function createCapturedValueEntry(
|
|
|
5549
5583
|
): { captureRef: TraceValueCaptureRef; entry?: TraceValueBatchEntry } | undefined {
|
|
5550
5584
|
if (!__FULL_VALUE_CAPTURE_ENABLED) return undefined;
|
|
5551
5585
|
|
|
5586
|
+
const limitedRaw = constrainLiveCapture(params.rawValue, params.capture.runtimeConfig, params.target);
|
|
5587
|
+
const omissions = isLiveCapture(params.capture.runtimeConfig) ? collectCaptureOmissions(limitedRaw) : [];
|
|
5588
|
+
|
|
5552
5589
|
const preferredLimits: FullValueCloneLimits = {
|
|
5553
5590
|
maxDepth: TRACE_FULL_VALUE_MAX_DEPTH,
|
|
5554
5591
|
maxKeys: TRACE_FULL_VALUE_MAX_KEYS,
|
|
@@ -5558,7 +5595,7 @@ function createCapturedValueEntry(
|
|
|
5558
5595
|
const buildMaskedCandidate = (limits: FullValueCloneLimits) => {
|
|
5559
5596
|
const projected = params.target === 'request.headers'
|
|
5560
5597
|
? sanitizeHeaders(params.rawValue, params.capture.captureHeaders)
|
|
5561
|
-
: cloneFullValueCandidate(
|
|
5598
|
+
: cloneFullValueCandidate(limitedRaw, params.capture, limits);
|
|
5562
5599
|
if (projected === undefined) return undefined;
|
|
5563
5600
|
return applyPrivacyThenMask(
|
|
5564
5601
|
params.target,
|
|
@@ -5579,7 +5616,7 @@ function createCapturedValueEntry(
|
|
|
5579
5616
|
}
|
|
5580
5617
|
|
|
5581
5618
|
const candidateSerialized = stableSerializeTraceValue(candidate);
|
|
5582
|
-
if (!candidateSerialized || candidateSerialized === previewSerialized) {
|
|
5619
|
+
if (!candidateSerialized || (candidateSerialized === previewSerialized && !omissions.length)) {
|
|
5583
5620
|
return undefined;
|
|
5584
5621
|
}
|
|
5585
5622
|
|
|
@@ -5605,6 +5642,7 @@ function createCapturedValueEntry(
|
|
|
5605
5642
|
status: 'stored',
|
|
5606
5643
|
valueId,
|
|
5607
5644
|
projectedKind: entry.projectedKind,
|
|
5645
|
+
...(omissions.length ? { complete: false, omissions } : {}),
|
|
5608
5646
|
},
|
|
5609
5647
|
entry,
|
|
5610
5648
|
};
|
|
@@ -5635,6 +5673,9 @@ async function createCapturedValueEntryAsync(
|
|
|
5635
5673
|
): Promise<{ captureRef: TraceValueCaptureRef; entry?: TraceValueBatchEntry } | undefined> {
|
|
5636
5674
|
if (!__FULL_VALUE_CAPTURE_ENABLED) return undefined;
|
|
5637
5675
|
|
|
5676
|
+
const limitedRaw = constrainLiveCapture(params.rawValue, params.capture.runtimeConfig, params.target);
|
|
5677
|
+
const omissions = isLiveCapture(params.capture.runtimeConfig) ? collectCaptureOmissions(limitedRaw) : [];
|
|
5678
|
+
|
|
5638
5679
|
const preferredLimits: FullValueCloneLimits = {
|
|
5639
5680
|
maxDepth: TRACE_FULL_VALUE_MAX_DEPTH,
|
|
5640
5681
|
maxKeys: TRACE_FULL_VALUE_MAX_KEYS,
|
|
@@ -5644,7 +5685,7 @@ async function createCapturedValueEntryAsync(
|
|
|
5644
5685
|
const buildMaskedCandidate = async (limits: FullValueCloneLimits) => {
|
|
5645
5686
|
const projected = params.target === 'request.headers'
|
|
5646
5687
|
? sanitizeHeaders(params.rawValue, params.capture.captureHeaders)
|
|
5647
|
-
: await cloneFullValueCandidateAsync(
|
|
5688
|
+
: await cloneFullValueCandidateAsync(limitedRaw, params.capture, limits);
|
|
5648
5689
|
if (projected === undefined) return undefined;
|
|
5649
5690
|
return applyPrivacyThenMaskAsync(
|
|
5650
5691
|
params.target,
|
|
@@ -5665,7 +5706,7 @@ async function createCapturedValueEntryAsync(
|
|
|
5665
5706
|
}
|
|
5666
5707
|
|
|
5667
5708
|
const candidateSerialized = stableSerializeTraceValue(candidate);
|
|
5668
|
-
if (!candidateSerialized || candidateSerialized === previewSerialized) {
|
|
5709
|
+
if (!candidateSerialized || (candidateSerialized === previewSerialized && !omissions.length)) {
|
|
5669
5710
|
return undefined;
|
|
5670
5711
|
}
|
|
5671
5712
|
|
|
@@ -5691,6 +5732,7 @@ async function createCapturedValueEntryAsync(
|
|
|
5691
5732
|
status: 'stored',
|
|
5692
5733
|
valueId,
|
|
5693
5734
|
projectedKind: entry.projectedKind,
|
|
5735
|
+
...(omissions.length ? { complete: false, omissions } : {}),
|
|
5694
5736
|
},
|
|
5695
5737
|
entry,
|
|
5696
5738
|
};
|
|
@@ -5892,6 +5934,8 @@ async function maybeCaptureDbValueFromCandidatesAsync(
|
|
|
5892
5934
|
// reproMiddleware — unchanged behavior + passive per-request trace
|
|
5893
5935
|
// ===================================================================
|
|
5894
5936
|
export type ReproMiddlewareConfig = IngestClientConfig & {
|
|
5937
|
+
/** Propagated recording mode. A tagged staging request never inherits live limits. */
|
|
5938
|
+
captureMode?: 'live' | 'staging';
|
|
5895
5939
|
/** API base used for backend-owned live session documents. Defaults to REPRO_API_BASE_URL/REPRO_API_BASE/privacy.apiBase. */
|
|
5896
5940
|
apiBase?: string;
|
|
5897
5941
|
/** Configure header capture/masking. Defaults to capturing with sensitive headers masked. */
|
|
@@ -5923,6 +5967,11 @@ export type ReproLiveActorDecision = ReproLiveActorArgs & {
|
|
|
5923
5967
|
};
|
|
5924
5968
|
|
|
5925
5969
|
export type ReproLiveModeConfig = {
|
|
5970
|
+
/** Stable chain sampling. Runtime updates apply to new chains; targets are evaluated on each claim. */
|
|
5971
|
+
liveCapture?: {
|
|
5972
|
+
sampleRate?: number;
|
|
5973
|
+
targets?: { actorIds?: string[]; labels?: Record<string, string>; expiresAt: number }[];
|
|
5974
|
+
};
|
|
5926
5975
|
enabled?: boolean;
|
|
5927
5976
|
backendOnly?: boolean;
|
|
5928
5977
|
/**
|
|
@@ -6112,6 +6161,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
|
|
|
6112
6161
|
const querySid = readRequestQueryParam(req, REPRO_QUERY_SESSION_PARAM);
|
|
6113
6162
|
const queryAid = readRequestQueryParam(req, REPRO_QUERY_ACTION_PARAM);
|
|
6114
6163
|
const queryTs = readRequestQueryNumber(req, REPRO_QUERY_START_PARAM);
|
|
6164
|
+
const queryCaptureMode = readRequestQueryParam(req, '__repro_mode');
|
|
6115
6165
|
if (querySid || queryAid || queryTs !== null) {
|
|
6116
6166
|
stripReproQueryParams(req);
|
|
6117
6167
|
}
|
|
@@ -6181,6 +6231,11 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
|
|
|
6181
6231
|
}],
|
|
6182
6232
|
}, { priority: 'high' });
|
|
6183
6233
|
}
|
|
6234
|
+
const captureMode = backendLiveContext || req.headers['x-repro-capture-mode'] === 'live' || queryCaptureMode === 'live'
|
|
6235
|
+
? 'live' : 'staging';
|
|
6236
|
+
const modeConfig = { ...captureCfg, captureMode } as ReproMiddlewareConfig;
|
|
6237
|
+
shareRuntimePrivacyState(captureCfg, modeConfig);
|
|
6238
|
+
captureCfg = modeConfig;
|
|
6184
6239
|
const resolvePrivacy = (): NormalizedRuntimePrivacyPolicy | null =>
|
|
6185
6240
|
getRuntimePrivacyState(captureCfg).policy ?? null;
|
|
6186
6241
|
|
|
@@ -7745,6 +7800,7 @@ function patchNodeFetchForPropagation() {
|
|
|
7745
7800
|
if (runtime.aid && !hasHttpHeader(headers, KAFKA_HEADER_ACTION_ID)) {
|
|
7746
7801
|
setHttpHeader(headers, KAFKA_HEADER_ACTION_ID, runtime.aid);
|
|
7747
7802
|
}
|
|
7803
|
+
if (runtime.sid) setHttpHeader(headers, 'x-repro-capture-mode', getCtx().captureScope?.liveCapture ? 'live' : 'staging');
|
|
7748
7804
|
if (runtime.traceId && !hasHttpHeader(headers, KAFKA_HEADER_TRACE_ID)) {
|
|
7749
7805
|
setHttpHeader(headers, KAFKA_HEADER_TRACE_ID, runtime.traceId);
|
|
7750
7806
|
}
|
|
@@ -7799,6 +7855,7 @@ function patchNodeHttpModuleRequest(mod: any) {
|
|
|
7799
7855
|
maybeSet(REQUEST_START_HEADER, String(alignedNow()));
|
|
7800
7856
|
maybeSet(KAFKA_HEADER_SESSION_ID, runtime.sid);
|
|
7801
7857
|
maybeSet(KAFKA_HEADER_ACTION_ID, runtime.aid);
|
|
7858
|
+
if (runtime.sid) maybeSet('x-repro-capture-mode', getCtx().captureScope?.liveCapture ? 'live' : 'staging');
|
|
7802
7859
|
maybeSet(KAFKA_HEADER_TRACE_ID, runtime.traceId);
|
|
7803
7860
|
maybeSet(KAFKA_HEADER_PARENT_SPAN_ID, runtime.parentSpanId);
|
|
7804
7861
|
maybeSet(KAFKA_HEADER_REQUEST_ID, runtime.requestRid);
|
|
@@ -7826,6 +7883,7 @@ type KafkaHeaderValue = string | Buffer | null | undefined;
|
|
|
7826
7883
|
type KafkaHeaderMap = Record<string, KafkaHeaderValue>;
|
|
7827
7884
|
type KafkaContextResolver = () => { sid?: string; aid?: string } | undefined;
|
|
7828
7885
|
type KafkaPropagationContext = {
|
|
7886
|
+
captureMode?: 'live' | 'staging';
|
|
7829
7887
|
sid: string | null;
|
|
7830
7888
|
aid: string | null;
|
|
7831
7889
|
traceId: string | null;
|
|
@@ -7990,6 +8048,11 @@ async function materializeKafkaRequestBody(
|
|
|
7990
8048
|
body: Record<string, any>,
|
|
7991
8049
|
captureScope?: RequestCaptureScopeState | null,
|
|
7992
8050
|
): Promise<InlinePrivacyMaterializationResult> {
|
|
8051
|
+
// Broker bodies have no separate full-value reference. Returning the trace
|
|
8052
|
+
// inline preview here would permanently replace message arrays with 20 items.
|
|
8053
|
+
if (shouldCapturePrivacyRaw(cfg)) {
|
|
8054
|
+
return { value: constrainLiveCapture(sanitizeInlinePrivacyValue(body), cfg, 'request.body', captureScope) };
|
|
8055
|
+
}
|
|
7993
8056
|
return materializeInlinePrivacyValueAsync(
|
|
7994
8057
|
'request.body',
|
|
7995
8058
|
sanitizeInlinePrivacyValue(body),
|
|
@@ -8053,7 +8116,8 @@ function resolveKafkaContext(cfg: KafkaJsPatchConfig, headers: any): KafkaPropag
|
|
|
8053
8116
|
null;
|
|
8054
8117
|
const requestRid = readKafkaHeader(headers, KAFKA_HEADER_REQUEST_ID);
|
|
8055
8118
|
|
|
8056
|
-
return { sid, aid, traceId, parentSpanId, requestRid
|
|
8119
|
+
return { sid, aid, traceId, parentSpanId, requestRid,
|
|
8120
|
+
captureMode: readKafkaHeader(headers, 'x-repro-capture-mode') === 'live' || ctx.captureScope?.liveCapture ? 'live' : 'staging' };
|
|
8057
8121
|
}
|
|
8058
8122
|
|
|
8059
8123
|
function withOptionalTraceContext<T>(traceId: string | null, fn: () => Promise<T>): Promise<T> {
|
|
@@ -8420,6 +8484,7 @@ function patchKafkaProducerInstance(producer: any, cfg: KafkaJsPatchConfig) {
|
|
|
8420
8484
|
[KAFKA_HEADER_REQUEST_ID]: publishRid,
|
|
8421
8485
|
[KAFKA_HEADER_SESSION_ID]: sid,
|
|
8422
8486
|
[KAFKA_HEADER_ACTION_ID]: aid,
|
|
8487
|
+
'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
|
|
8423
8488
|
}),
|
|
8424
8489
|
};
|
|
8425
8490
|
});
|
|
@@ -8552,6 +8617,7 @@ function patchKafkaProducerInstance(producer: any, cfg: KafkaJsPatchConfig) {
|
|
|
8552
8617
|
[KAFKA_HEADER_REQUEST_ID]: publishRid,
|
|
8553
8618
|
[KAFKA_HEADER_SESSION_ID]: sid,
|
|
8554
8619
|
[KAFKA_HEADER_ACTION_ID]: aid,
|
|
8620
|
+
'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
|
|
8555
8621
|
}),
|
|
8556
8622
|
};
|
|
8557
8623
|
}),
|
|
@@ -8668,6 +8734,7 @@ function wrapKafkaEachMessageHandler(eachMessage: any, cfg: KafkaJsPatchConfig,
|
|
|
8668
8734
|
const consumeRid = nextRequestId(`${topic}-${message?.offset ?? alignedNow()}`);
|
|
8669
8735
|
const traceId = consumeRid;
|
|
8670
8736
|
const captureScope = createRequestCaptureScope(consumeRid, 'kafka', cfg);
|
|
8737
|
+
captureScope.liveCapture = runtime.captureMode === 'live';
|
|
8671
8738
|
emitRequestCaptureStart(cfg, sid, aid, captureScope);
|
|
8672
8739
|
const rawTraceEvents: any[] = [];
|
|
8673
8740
|
const maskReq: MaskRequestContext = {
|
|
@@ -8778,6 +8845,7 @@ function wrapKafkaEachBatchHandler(eachBatch: any, cfg: KafkaJsPatchConfig, cons
|
|
|
8778
8845
|
const consumeRid = nextRequestId(`${topic}-batch-${alignedNow()}`);
|
|
8779
8846
|
const traceId = consumeRid;
|
|
8780
8847
|
const captureScope = createRequestCaptureScope(consumeRid, 'kafka', cfg);
|
|
8848
|
+
captureScope.liveCapture = runtime.captureMode === 'live';
|
|
8781
8849
|
emitRequestCaptureStart(cfg, sid, aid, captureScope);
|
|
8782
8850
|
const rawTraceEvents: any[] = [];
|
|
8783
8851
|
const maskReq: MaskRequestContext = {
|
|
@@ -8972,6 +9040,7 @@ export function patchKafkaJs(cfg: KafkaJsPatchConfig) {
|
|
|
8972
9040
|
type AmqpHeaderMap = Record<string, any>;
|
|
8973
9041
|
type AmqpContextResolver = () => { sid?: string; aid?: string } | undefined;
|
|
8974
9042
|
type AmqpPropagationContext = {
|
|
9043
|
+
captureMode?: 'live' | 'staging';
|
|
8975
9044
|
sid: string | null;
|
|
8976
9045
|
aid: string | null;
|
|
8977
9046
|
traceId: string | null;
|
|
@@ -9240,7 +9309,8 @@ function resolveAmqpContext(cfg: AmqplibPatchConfig, headers: any): AmqpPropagat
|
|
|
9240
9309
|
null;
|
|
9241
9310
|
const requestRid = readAmqpHeader(headers, KAFKA_HEADER_REQUEST_ID);
|
|
9242
9311
|
|
|
9243
|
-
return { sid, aid, traceId, parentSpanId, requestRid
|
|
9312
|
+
return { sid, aid, traceId, parentSpanId, requestRid,
|
|
9313
|
+
captureMode: readAmqpHeader(headers, 'x-repro-capture-mode') === 'live' || ctx.captureScope?.liveCapture ? 'live' : 'staging' };
|
|
9244
9314
|
}
|
|
9245
9315
|
|
|
9246
9316
|
function withOptionalTraceContextSync<T>(traceId: string | null, fn: () => T): T {
|
|
@@ -9478,6 +9548,7 @@ function patchAmqplibPublishMethod(
|
|
|
9478
9548
|
[KAFKA_HEADER_REQUEST_ID]: publishRid,
|
|
9479
9549
|
[KAFKA_HEADER_SESSION_ID]: sid,
|
|
9480
9550
|
[KAFKA_HEADER_ACTION_ID]: aid,
|
|
9551
|
+
'x-repro-capture-mode': ctx.captureScope?.liveCapture ? 'live' : 'staging',
|
|
9481
9552
|
};
|
|
9482
9553
|
const shouldPropagate = hasAmqpPropagationValues(propagationValues);
|
|
9483
9554
|
const callbackIndex = args.length > 0 && typeof args[args.length - 1] === 'function'
|
|
@@ -9586,6 +9657,7 @@ function wrapAmqplibConsumeHandler(onMessage: any, cfg: AmqplibPatchConfig, queu
|
|
|
9586
9657
|
const consumeRid = nextRequestId(`${queueName}-${deliveryTag}`);
|
|
9587
9658
|
const traceId = consumeRid;
|
|
9588
9659
|
const captureScope = createRequestCaptureScope(consumeRid, 'amqp', cfg);
|
|
9660
|
+
captureScope.liveCapture = runtime.captureMode === 'live';
|
|
9589
9661
|
emitRequestCaptureStart(cfg, sid, aid, captureScope);
|
|
9590
9662
|
const rawTraceEvents: any[] = [];
|
|
9591
9663
|
const maskReq: MaskRequestContext = {
|
package/src/ingest/client.ts
CHANGED
|
@@ -44,6 +44,7 @@ type QueuedIngestBatch = {
|
|
|
44
44
|
sessionId: string;
|
|
45
45
|
entries: LegacyEntry[];
|
|
46
46
|
attempts: number;
|
|
47
|
+
enqueuedAt: number;
|
|
47
48
|
priority: 'normal' | 'high' | 'critical';
|
|
48
49
|
};
|
|
49
50
|
|
|
@@ -59,11 +60,13 @@ let draining = false;
|
|
|
59
60
|
let criticalDraining = false;
|
|
60
61
|
let backoffMs = 0;
|
|
61
62
|
let criticalBackoffMs = 0;
|
|
62
|
-
let shouldDeferDrain: (() => boolean) | null = null;
|
|
63
|
+
let shouldDeferDrain: ((enqueuedAt?: number) => boolean) | null = null;
|
|
63
64
|
let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
|
|
65
|
+
const throttledUntil = new Map<string, number>();
|
|
66
|
+
const throttleDelay = (config: IngestClientConfig) => Math.max(0, (throttledUntil.get(resolveIngestUrl(config)) ?? 0) - Date.now());
|
|
64
67
|
|
|
65
68
|
export const configureIngestQueueDrain = (options: {
|
|
66
|
-
shouldDefer?: (() => boolean) | null;
|
|
69
|
+
shouldDefer?: ((enqueuedAt?: number) => boolean) | null;
|
|
67
70
|
deferMs?: number;
|
|
68
71
|
}): void => {
|
|
69
72
|
shouldDeferDrain = options.shouldDefer ?? null;
|
|
@@ -77,7 +80,7 @@ export const kickIngestQueueDrain = (): void => {
|
|
|
77
80
|
scheduleCriticalDrain();
|
|
78
81
|
}
|
|
79
82
|
if (ingestQueue.length > 0 && !drainTimer) {
|
|
80
|
-
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
83
|
+
scheduleDrain(shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt) ? deferDrainMs : 0);
|
|
81
84
|
}
|
|
82
85
|
};
|
|
83
86
|
|
|
@@ -220,7 +223,9 @@ const sendIngestEntries = async (
|
|
|
220
223
|
config: IngestClientConfig,
|
|
221
224
|
sessionId: string,
|
|
222
225
|
entries: LegacyEntry[],
|
|
223
|
-
): Promise<boolean> => {
|
|
226
|
+
): Promise<boolean | 'throttled' | 'denied'> => {
|
|
227
|
+
if (throttleDelay(config) > 0) return 'throttled';
|
|
228
|
+
throttledUntil.delete(resolveIngestUrl(config));
|
|
224
229
|
if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
|
|
225
230
|
return true;
|
|
226
231
|
}
|
|
@@ -255,6 +260,18 @@ const sendIngestEntries = async (
|
|
|
255
260
|
body: JSON.stringify({ events }),
|
|
256
261
|
...(controller ? { signal: controller.signal } : {}),
|
|
257
262
|
});
|
|
263
|
+
if (response.status === 429) {
|
|
264
|
+
const retryAfter = response.headers?.get?.('retry-after');
|
|
265
|
+
const seconds = retryAfter ? Number(retryAfter) : NaN;
|
|
266
|
+
const date = retryAfter ? Date.parse(retryAfter) : NaN;
|
|
267
|
+
const delay = Number.isFinite(seconds) ? seconds * 1000 : Number.isFinite(date) ? date - Date.now() : 1000;
|
|
268
|
+
throttledUntil.set(resolveIngestUrl(config), Date.now() + Math.max(1000, Math.min(86400_000, delay)));
|
|
269
|
+
return 'throttled';
|
|
270
|
+
}
|
|
271
|
+
if (response.status === 402 || response.status === 410) {
|
|
272
|
+
console.warn(`[repro] Upload refused for ${sessionId}: ${response.status === 402 ? 'capture quota exhausted' : 'capture deleted, expired or failed'}. This batch will not be retried.`);
|
|
273
|
+
return 'denied';
|
|
274
|
+
}
|
|
258
275
|
return response.ok;
|
|
259
276
|
} catch {
|
|
260
277
|
// swallow in SDK
|
|
@@ -272,12 +289,12 @@ const scheduleDrain = (delayMs = 0): void => {
|
|
|
272
289
|
if (drainTimer) {
|
|
273
290
|
return;
|
|
274
291
|
}
|
|
275
|
-
const nextDelay = shouldDeferDrain?.()
|
|
292
|
+
const nextDelay = shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)
|
|
276
293
|
? Math.max(delayMs, deferDrainMs)
|
|
277
294
|
: delayMs;
|
|
278
295
|
drainTimer = setTimeout(() => {
|
|
279
296
|
drainTimer = null;
|
|
280
|
-
if (shouldDeferDrain?.()) {
|
|
297
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
281
298
|
scheduleDrain(deferDrainMs);
|
|
282
299
|
return;
|
|
283
300
|
}
|
|
@@ -317,7 +334,11 @@ const drainCriticalIngestQueue = async (): Promise<void> => {
|
|
|
317
334
|
selected.batch.sessionId,
|
|
318
335
|
selected.batch.entries,
|
|
319
336
|
);
|
|
320
|
-
if (ok) {
|
|
337
|
+
if (ok === 'throttled') {
|
|
338
|
+
scheduleCriticalDrain(throttleDelay(selected.batch.config));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (ok === true || ok === 'denied') {
|
|
321
342
|
criticalIngestQueue.splice(0, selected.queueItems);
|
|
322
343
|
criticalBackoffMs = 0;
|
|
323
344
|
continue;
|
|
@@ -345,14 +366,14 @@ const drainIngestQueue = async (): Promise<void> => {
|
|
|
345
366
|
if (draining) {
|
|
346
367
|
return;
|
|
347
368
|
}
|
|
348
|
-
if (shouldDeferDrain?.()) {
|
|
369
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
349
370
|
scheduleDrain(deferDrainMs);
|
|
350
371
|
return;
|
|
351
372
|
}
|
|
352
373
|
draining = true;
|
|
353
374
|
try {
|
|
354
375
|
while (ingestQueue.length > 0) {
|
|
355
|
-
if (shouldDeferDrain?.()) {
|
|
376
|
+
if (shouldDeferDrain?.(ingestQueue[0]?.enqueuedAt)) {
|
|
356
377
|
scheduleDrain(deferDrainMs);
|
|
357
378
|
return;
|
|
358
379
|
}
|
|
@@ -362,7 +383,11 @@ const drainIngestQueue = async (): Promise<void> => {
|
|
|
362
383
|
}
|
|
363
384
|
const batch = selected.batch;
|
|
364
385
|
const ok = await sendIngestEntries(batch.config, batch.sessionId, batch.entries);
|
|
365
|
-
if (ok) {
|
|
386
|
+
if (ok === 'throttled') {
|
|
387
|
+
scheduleDrain(throttleDelay(batch.config));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (ok === true || ok === 'denied') {
|
|
366
391
|
ingestQueue.splice(0, selected.queueItems);
|
|
367
392
|
backoffMs = 0;
|
|
368
393
|
continue;
|
|
@@ -420,6 +445,7 @@ export const enqueueIngestEntries = (
|
|
|
420
445
|
sessionId,
|
|
421
446
|
entries: entries.slice(),
|
|
422
447
|
attempts: 0,
|
|
448
|
+
enqueuedAt: Date.now(),
|
|
423
449
|
priority,
|
|
424
450
|
};
|
|
425
451
|
if (priority === 'critical') {
|