@reproapp/node-sdk 0.0.13 → 0.0.15

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/dist/index.js CHANGED
@@ -783,6 +783,40 @@ function attachSpanContext(target, span) {
783
783
  }
784
784
  return target;
785
785
  }
786
+ function createRequestCaptureScope(requestRid, transport) {
787
+ return {
788
+ requestRid,
789
+ transport,
790
+ backendRequestCount: 0,
791
+ traceBatchCount: 0,
792
+ dbChangeCount: 0,
793
+ emailEventCount: 0,
794
+ };
795
+ }
796
+ function buildRequestCaptureManifestPayload(scope, traceBatchTotal, closedAtMs) {
797
+ const expectedTraceBatches = Math.max(0, Math.trunc(traceBatchTotal));
798
+ const closedAt = typeof closedAtMs === 'number' && Number.isFinite(closedAtMs)
799
+ ? closedAtMs
800
+ : alignedNow();
801
+ return {
802
+ scopeId: `req:${scope.requestRid}`,
803
+ requestRid: scope.requestRid,
804
+ transport: scope.transport,
805
+ expected: {
806
+ backendRequest: scope.backendRequestCount,
807
+ traceBatch: expectedTraceBatches,
808
+ dbChange: scope.dbChangeCount,
809
+ emailEvent: scope.emailEventCount,
810
+ },
811
+ traceBatch: expectedTraceBatches > 0
812
+ ? {
813
+ from: 0,
814
+ to: expectedTraceBatches - 1,
815
+ }
816
+ : undefined,
817
+ closedAt,
818
+ };
819
+ }
786
820
  const als = new async_hooks_1.AsyncLocalStorage();
787
821
  const getCtx = () => als.getStore() || {};
788
822
  function currentClockSkewMs() {
@@ -3879,6 +3913,7 @@ function reproMiddleware(cfg) {
3879
3913
  const clockSkewMs = headerTs !== null ? headerTs - requestStartRaw : 0;
3880
3914
  const requestEpochMs = headerTs !== null ? headerTs : requestStartRaw + clockSkewMs;
3881
3915
  const rid = nextRequestId(requestEpochMs);
3916
+ const captureScope = createRequestCaptureScope(rid, 'http');
3882
3917
  const t0 = requestStartRaw;
3883
3918
  const url = req.originalUrl || req.url || '/';
3884
3919
  const urlPathOnly = (url || '/').split('?')[0] || '/';
@@ -3919,7 +3954,7 @@ function reproMiddleware(cfg) {
3919
3954
  }
3920
3955
  return fn();
3921
3956
  };
3922
- runInTrace(() => als.run({ sid, aid, rid, clockSkewMs, excludedSpanIds: new Set(), maskReq }, () => {
3957
+ runInTrace(() => als.run({ sid, aid, rid, clockSkewMs, excludedSpanIds: new Set(), maskReq, captureScope }, () => {
3923
3958
  const events = [];
3924
3959
  let endpointTrace = null;
3925
3960
  let preferredAppTrace = null;
@@ -3928,6 +3963,7 @@ function reproMiddleware(cfg) {
3928
3963
  let flushed = false;
3929
3964
  let finished = false;
3930
3965
  let finishedAt = null;
3966
+ let finishedAtAligned = null;
3931
3967
  let lastEventAt = Date.now();
3932
3968
  let idleTimer = null;
3933
3969
  let hardStopTimer = null;
@@ -4190,6 +4226,7 @@ function reproMiddleware(cfg) {
4190
4226
  t: requestEpochMs,
4191
4227
  }]
4192
4228
  });
4229
+ captureScope.backendRequestCount += 1;
4193
4230
  }
4194
4231
  catch {
4195
4232
  // never break user code
@@ -4280,6 +4317,7 @@ function reproMiddleware(cfg) {
4280
4317
  finished = true;
4281
4318
  if (finishedAt === null) {
4282
4319
  finishedAt = Date.now();
4320
+ finishedAtAligned = alignTimestamp(finishedAt);
4283
4321
  }
4284
4322
  if (capturedBody === undefined && chunks.length) {
4285
4323
  const buf = Buffer.isBuffer(chunks[0])
@@ -4304,6 +4342,7 @@ function reproMiddleware(cfg) {
4304
4342
  : sortTraceEventsChronologically(baseEvents);
4305
4343
  const traceBatches = chunkTraceEventsForTransport(orderedEvents, rid, aid);
4306
4344
  if (traceBatches.length) {
4345
+ captureScope.traceBatchCount = traceBatches.length;
4307
4346
  for (let i = 0; i < traceBatches.length; i++) {
4308
4347
  const batch = traceBatches[i];
4309
4348
  const traceValues = collectBatchTraceValueEntries(batch, i);
@@ -4322,6 +4361,17 @@ function reproMiddleware(cfg) {
4322
4361
  });
4323
4362
  }
4324
4363
  }
4364
+ else {
4365
+ captureScope.traceBatchCount = 0;
4366
+ }
4367
+ post(cfg, sid, {
4368
+ entries: [{
4369
+ actionId: aid,
4370
+ requestRid: rid,
4371
+ requestCaptureManifest: buildRequestCaptureManifestPayload(captureScope, traceBatches.length, finishedAtAligned),
4372
+ t: finishedAtAligned ?? alignedNow(),
4373
+ }],
4374
+ });
4325
4375
  }
4326
4376
  catch {
4327
4377
  // never break user code
@@ -4786,6 +4836,12 @@ function dehydrateComplexValue(value) {
4786
4836
  }
4787
4837
  function emitDbQuery(cfg, sid, aid, payload) {
4788
4838
  const ctx = getCtx();
4839
+ const spanContext = payload?.spanContext ?? captureSpanContextFromTracer();
4840
+ if (!shouldCaptureDbSpan(spanContext))
4841
+ return;
4842
+ if (ctx.captureScope) {
4843
+ ctx.captureScope.dbChangeCount += 1;
4844
+ }
4789
4845
  const runCtx = {
4790
4846
  sid: ctx.sid,
4791
4847
  aid: ctx.aid,
@@ -4793,6 +4849,7 @@ function emitDbQuery(cfg, sid, aid, payload) {
4793
4849
  clockSkewMs: ctx.clockSkewMs,
4794
4850
  maskReq: ctx.maskReq,
4795
4851
  excludedSpanIds: ctx.excludedSpanIds ? new Set(ctx.excludedSpanIds) : undefined,
4852
+ captureScope: ctx.captureScope,
4796
4853
  };
4797
4854
  scheduleSdkBackgroundWork(async () => {
4798
4855
  await als.run(runCtx, async () => {
@@ -5575,9 +5632,9 @@ function buildKafkaTraceEntries(actionId, requestRid, events) {
5575
5632
  t: alignedNow(),
5576
5633
  }));
5577
5634
  }
5578
- function scheduleKafkaTraceFlush(cfg, kafkaCtx, actionId, requestRid, rawTraceEvents, maskReq) {
5635
+ function scheduleKafkaTraceFlush(cfg, kafkaCtx, actionId, requestRid, rawTraceEvents, maskReq, captureScope) {
5579
5636
  const sid = kafkaCtx.sid;
5580
- if (!sid || !rawTraceEvents.length)
5637
+ if (!sid)
5581
5638
  return;
5582
5639
  const rawEvents = rawTraceEvents.slice();
5583
5640
  scheduleSdkBackgroundWork(async () => {
@@ -5600,9 +5657,22 @@ function scheduleKafkaTraceFlush(cfg, kafkaCtx, actionId, requestRid, rawTraceEv
5600
5657
  resolvePrivacy: () => getRuntimePrivacyState(cfg).policy ?? null,
5601
5658
  });
5602
5659
  const entries = buildKafkaTraceEntries(actionId, requestRid, materializedEvents);
5660
+ if (captureScope) {
5661
+ captureScope.traceBatchCount = entries.length;
5662
+ }
5603
5663
  if (entries.length) {
5604
5664
  post(cfg, sid, { entries });
5605
5665
  }
5666
+ if (captureScope) {
5667
+ post(cfg, sid, {
5668
+ entries: [{
5669
+ actionId: actionId ?? null,
5670
+ requestRid,
5671
+ requestCaptureManifest: buildRequestCaptureManifestPayload(captureScope, entries.length),
5672
+ t: alignedNow(),
5673
+ }],
5674
+ });
5675
+ }
5606
5676
  });
5607
5677
  }, { priority: 'high' });
5608
5678
  }
@@ -5619,6 +5689,7 @@ function patchKafkaProducerInstance(producer, cfg) {
5619
5689
  const aid = ctx.aid ?? fallback.aid ?? null;
5620
5690
  const span = captureSpanContextFromTracer();
5621
5691
  const publishRid = nextRequestId(alignedNow());
5692
+ const captureScope = createRequestCaptureScope(publishRid, 'kafka');
5622
5693
  const payloadObj = payload && typeof payload === 'object' ? payload : {};
5623
5694
  const topic = sanitizeKafkaTopic(payloadObj.topic);
5624
5695
  const rawMessages = Array.isArray(payloadObj.messages) ? payloadObj.messages : [];
@@ -5705,6 +5776,15 @@ function patchKafkaProducerInstance(producer, cfg) {
5705
5776
  },
5706
5777
  ],
5707
5778
  });
5779
+ captureScope.backendRequestCount += 1;
5780
+ post(cfg, sid, {
5781
+ entries: [{
5782
+ actionId: aid ?? null,
5783
+ requestRid: publishRid,
5784
+ requestCaptureManifest: buildRequestCaptureManifestPayload(captureScope, 0),
5785
+ t: alignedNow(),
5786
+ }],
5787
+ });
5708
5788
  }
5709
5789
  };
5710
5790
  }
@@ -5717,6 +5797,7 @@ function patchKafkaProducerInstance(producer, cfg) {
5717
5797
  const aid = ctx.aid ?? fallback.aid ?? null;
5718
5798
  const span = captureSpanContextFromTracer();
5719
5799
  const publishRid = nextRequestId(alignedNow());
5800
+ const captureScope = createRequestCaptureScope(publishRid, 'kafka');
5720
5801
  const payloadObj = payload && typeof payload === 'object' ? payload : {};
5721
5802
  const topicMessages = Array.isArray(payloadObj.topicMessages)
5722
5803
  ? payloadObj.topicMessages
@@ -5834,6 +5915,15 @@ function patchKafkaProducerInstance(producer, cfg) {
5834
5915
  },
5835
5916
  ],
5836
5917
  });
5918
+ captureScope.backendRequestCount += 1;
5919
+ post(cfg, sid, {
5920
+ entries: [{
5921
+ actionId: aid ?? null,
5922
+ requestRid: publishRid,
5923
+ requestCaptureManifest: buildRequestCaptureManifestPayload(captureScope, 0),
5924
+ t: alignedNow(),
5925
+ }],
5926
+ });
5837
5927
  }
5838
5928
  };
5839
5929
  }
@@ -5848,6 +5938,7 @@ function wrapKafkaEachMessageHandler(eachMessage, cfg, consumer) {
5848
5938
  const aid = runtime.aid;
5849
5939
  const consumeRid = nextRequestId(`${topic}-${message?.offset ?? alignedNow()}`);
5850
5940
  const traceId = consumeRid;
5941
+ const captureScope = createRequestCaptureScope(consumeRid, 'kafka');
5851
5942
  const rawTraceEvents = [];
5852
5943
  const maskReq = {
5853
5944
  method: 'KAFKA_CONSUME',
@@ -5862,6 +5953,7 @@ function wrapKafkaEachMessageHandler(eachMessage, cfg, consumer) {
5862
5953
  excludedSpanIds: new Set(),
5863
5954
  maskReq,
5864
5955
  privacyTasks: new Set(),
5956
+ captureScope,
5865
5957
  };
5866
5958
  let unsubscribe;
5867
5959
  const invoke = async () => {
@@ -5944,7 +6036,8 @@ function wrapKafkaEachMessageHandler(eachMessage, cfg, consumer) {
5944
6036
  t: alignedNow(),
5945
6037
  }],
5946
6038
  });
5947
- scheduleKafkaTraceFlush(cfg, kafkaCtx, aid, consumeRid, rawTraceEvents, maskReq);
6039
+ captureScope.backendRequestCount += 1;
6040
+ scheduleKafkaTraceFlush(cfg, kafkaCtx, aid, consumeRid, rawTraceEvents, maskReq, captureScope);
5948
6041
  }
5949
6042
  };
5950
6043
  }
@@ -5959,6 +6052,7 @@ function wrapKafkaEachBatchHandler(eachBatch, cfg, consumer) {
5959
6052
  const aid = runtime.aid;
5960
6053
  const consumeRid = nextRequestId(`${topic}-batch-${alignedNow()}`);
5961
6054
  const traceId = consumeRid;
6055
+ const captureScope = createRequestCaptureScope(consumeRid, 'kafka');
5962
6056
  const rawTraceEvents = [];
5963
6057
  const maskReq = {
5964
6058
  method: 'KAFKA_CONSUME_BATCH',
@@ -5973,6 +6067,7 @@ function wrapKafkaEachBatchHandler(eachBatch, cfg, consumer) {
5973
6067
  excludedSpanIds: new Set(),
5974
6068
  maskReq,
5975
6069
  privacyTasks: new Set(),
6070
+ captureScope,
5976
6071
  };
5977
6072
  let unsubscribe;
5978
6073
  const invoke = async () => {
@@ -6053,7 +6148,8 @@ function wrapKafkaEachBatchHandler(eachBatch, cfg, consumer) {
6053
6148
  t: alignedNow(),
6054
6149
  }],
6055
6150
  });
6056
- scheduleKafkaTraceFlush(cfg, kafkaCtx, aid, consumeRid, rawTraceEvents, maskReq);
6151
+ captureScope.backendRequestCount += 1;
6152
+ scheduleKafkaTraceFlush(cfg, kafkaCtx, aid, consumeRid, rawTraceEvents, maskReq, captureScope);
6057
6153
  }
6058
6154
  };
6059
6155
  }
@@ -6705,10 +6801,14 @@ function patchSendgridFromModuleExports(sgMail, cfg) {
6705
6801
  const aid = ctx.aid ?? cfg.resolveContext?.()?.aid;
6706
6802
  if (!sid)
6707
6803
  return;
6804
+ if (ctx.captureScope) {
6805
+ ctx.captureScope.emailEventCount += 1;
6806
+ }
6708
6807
  const norm = normalizeSendgridMessage(rawMsg);
6709
6808
  post(cfg, sid, {
6710
6809
  entries: [{
6711
6810
  actionId: aid ?? null,
6811
+ requestRid: ctx.rid ?? null,
6712
6812
  email: {
6713
6813
  provider: 'sendgrid',
6714
6814
  kind,
@@ -14,6 +14,13 @@ const DEFAULT_QUEUE_MAX_ITEMS = (() => {
14
14
  const DEFAULT_QUEUE_MAX_ATTEMPTS = 5;
15
15
  const DEFAULT_QUEUE_BACKOFF_MAX_MS = 4000;
16
16
  const DEFAULT_DEFER_DRAIN_MS = 25;
17
+ const DEFAULT_INGEST_REQUEST_TIMEOUT_MS = (() => {
18
+ const env = Number(typeof process !== 'undefined' ? process?.env?.REPRO_INGEST_REQUEST_TIMEOUT_MS : undefined);
19
+ if (Number.isFinite(env) && env >= 0) {
20
+ return Math.trunc(env);
21
+ }
22
+ return 5000;
23
+ })();
17
24
  const ingestQueue = [];
18
25
  let drainTimer = null;
19
26
  let draining = false;
@@ -65,11 +72,29 @@ const sendIngestEntries = async (config, sessionId, entries) => {
65
72
  if (!events.length) {
66
73
  return true;
67
74
  }
75
+ let timeout = null;
76
+ const Controller = globalThis.AbortController;
77
+ const controller = DEFAULT_INGEST_REQUEST_TIMEOUT_MS > 0 && typeof Controller === 'function'
78
+ ? new Controller()
79
+ : null;
68
80
  try {
81
+ if (controller) {
82
+ timeout = setTimeout(() => {
83
+ try {
84
+ controller.abort();
85
+ }
86
+ catch { }
87
+ }, DEFAULT_INGEST_REQUEST_TIMEOUT_MS);
88
+ try {
89
+ timeout.unref?.();
90
+ }
91
+ catch { }
92
+ }
69
93
  const response = await fetch(resolveIngestUrl(config), {
70
94
  method: 'POST',
71
95
  headers: toHeaders(config),
72
96
  body: JSON.stringify({ events }),
97
+ ...(controller ? { signal: controller.signal } : {}),
73
98
  });
74
99
  return response.ok;
75
100
  }
@@ -77,6 +102,14 @@ const sendIngestEntries = async (config, sessionId, entries) => {
77
102
  // swallow in SDK
78
103
  return false;
79
104
  }
105
+ finally {
106
+ if (timeout) {
107
+ try {
108
+ clearTimeout(timeout);
109
+ }
110
+ catch { }
111
+ }
112
+ }
80
113
  };
81
114
  const scheduleDrain = (delayMs = 0) => {
82
115
  if (drainTimer) {
@@ -6,6 +6,10 @@ const toIso = (timestamp) => {
6
6
  return new Date(numeric).toISOString();
7
7
  };
8
8
  const inferEventType = (entry) => {
9
+ if (entry.requestCaptureManifest !== undefined)
10
+ return 'request_capture_manifest';
11
+ if (entry.sessionCaptureManifest !== undefined)
12
+ return 'session_capture_manifest';
9
13
  if (entry.request !== undefined)
10
14
  return 'backend_request';
11
15
  if (entry.db !== undefined)
@@ -17,6 +17,8 @@ export interface LegacyEntry {
17
17
  requestRid?: string | null;
18
18
  request?: unknown;
19
19
  requestValues?: unknown[];
20
+ requestCaptureManifest?: unknown;
21
+ sessionCaptureManifest?: unknown;
20
22
  db?: unknown;
21
23
  dbValues?: unknown[];
22
24
  trace?: unknown[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reproapp/node-sdk",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Repro Nest SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "build": "tsc -p tsconfig.json",
13
13
  "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
14
14
  "prepublishOnly": "npm run build",
15
- "test": "npm run build && node test/unawaited.test.js && node test/integration-unawaited.js && node test/request-flush-timing.test.js && node test/express-trace-http-args.test.js && node test/trace-batch-size.test.js && node test/sdk-background-priority.test.js && node test/ingest-queue-retention.test.js && node -r ./tracer/register test/promise-map.test.js && node test/disable-subtree.test.js && node test/circular-capture.test.js && node test/wrap-plugin-arrow-args.test.js && node test/privacy-runtime-policy.test.js && node test/runtime-privacy-materialization.test.js && node test/kafka-runtime-privacy-policy.test.js"
15
+ "test": "npm run build && node test/unawaited.test.js && node test/integration-unawaited.js && node test/request-flush-timing.test.js && node test/express-trace-http-args.test.js && node test/trace-batch-size.test.js && node test/sdk-background-priority.test.js && node test/ingest-queue-retention.test.js && node test/ingest-timeout.test.js && node -r ./tracer/register test/promise-map.test.js && node test/disable-subtree.test.js && node test/circular-capture.test.js && node test/wrap-plugin-arrow-args.test.js && node test/privacy-runtime-policy.test.js && node test/runtime-privacy-materialization.test.js && node test/kafka-runtime-privacy-policy.test.js"
16
16
  },
17
17
  "peerDependencies": {
18
18
  "express": "^5.1.0",
package/src/index.ts CHANGED
@@ -1152,7 +1152,63 @@ type Ctx = {
1152
1152
  excludedSpanIds?: Set<string>;
1153
1153
  maskReq?: MaskRequestContext;
1154
1154
  privacyTasks?: Set<Promise<void>>;
1155
+ captureScope?: RequestCaptureScopeState;
1155
1156
  };
1157
+
1158
+ type RequestCaptureScopeState = {
1159
+ requestRid: string;
1160
+ transport: 'http' | 'kafka' | 'background';
1161
+ backendRequestCount: number;
1162
+ traceBatchCount: number;
1163
+ dbChangeCount: number;
1164
+ emailEventCount: number;
1165
+ };
1166
+
1167
+ function createRequestCaptureScope(
1168
+ requestRid: string,
1169
+ transport: RequestCaptureScopeState['transport'],
1170
+ ): RequestCaptureScopeState {
1171
+ return {
1172
+ requestRid,
1173
+ transport,
1174
+ backendRequestCount: 0,
1175
+ traceBatchCount: 0,
1176
+ dbChangeCount: 0,
1177
+ emailEventCount: 0,
1178
+ };
1179
+ }
1180
+
1181
+ function buildRequestCaptureManifestPayload(
1182
+ scope: RequestCaptureScopeState,
1183
+ traceBatchTotal: number,
1184
+ closedAtMs?: number | null,
1185
+ ): Record<string, any> {
1186
+ const expectedTraceBatches = Math.max(0, Math.trunc(traceBatchTotal));
1187
+ const closedAt =
1188
+ typeof closedAtMs === 'number' && Number.isFinite(closedAtMs)
1189
+ ? closedAtMs
1190
+ : alignedNow();
1191
+ return {
1192
+ scopeId: `req:${scope.requestRid}`,
1193
+ requestRid: scope.requestRid,
1194
+ transport: scope.transport,
1195
+ expected: {
1196
+ backendRequest: scope.backendRequestCount,
1197
+ traceBatch: expectedTraceBatches,
1198
+ dbChange: scope.dbChangeCount,
1199
+ emailEvent: scope.emailEventCount,
1200
+ },
1201
+ traceBatch:
1202
+ expectedTraceBatches > 0
1203
+ ? {
1204
+ from: 0,
1205
+ to: expectedTraceBatches - 1,
1206
+ }
1207
+ : undefined,
1208
+ closedAt,
1209
+ };
1210
+ }
1211
+
1156
1212
  const als = new AsyncLocalStorage<Ctx>();
1157
1213
  const getCtx = () => als.getStore() || {};
1158
1214
 
@@ -4747,6 +4803,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
4747
4803
  const clockSkewMs = headerTs !== null ? headerTs - requestStartRaw : 0;
4748
4804
  const requestEpochMs = headerTs !== null ? headerTs : requestStartRaw + clockSkewMs;
4749
4805
  const rid = nextRequestId(requestEpochMs);
4806
+ const captureScope = createRequestCaptureScope(rid, 'http');
4750
4807
  const t0 = requestStartRaw;
4751
4808
  const url = (req as any).originalUrl || req.url || '/';
4752
4809
  const urlPathOnly = (url || '/').split('?')[0] || '/';
@@ -4784,7 +4841,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
4784
4841
  return fn();
4785
4842
  };
4786
4843
 
4787
- runInTrace(() => als.run({ sid, aid, rid, clockSkewMs, excludedSpanIds: new Set<string>(), maskReq }, () => {
4844
+ runInTrace(() => als.run({ sid, aid, rid, clockSkewMs, excludedSpanIds: new Set<string>(), maskReq, captureScope }, () => {
4788
4845
  const events: PendingTraceEventRecord[] = [];
4789
4846
  let endpointTrace: EndpointTraceInfo | null = null;
4790
4847
  let preferredAppTrace: EndpointTraceInfo | null = null;
@@ -4793,15 +4850,16 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
4793
4850
  let flushed = false;
4794
4851
  let finished = false;
4795
4852
  let finishedAt: number | null = null;
4853
+ let finishedAtAligned: number | null = null;
4796
4854
  let lastEventAt: number = Date.now();
4797
4855
  let idleTimer: NodeJS.Timeout | null = null;
4798
- let hardStopTimer: NodeJS.Timeout | null = null;
4856
+ let hardStopTimer: NodeJS.Timeout | null = null;
4799
4857
  let flushPayload: null | (() => Promise<void>) = null;
4800
4858
  let requestCaptureScheduled = false;
4801
4859
  let sessionDrainWait: Promise<void> | null = null;
4802
4860
  const activeSpans = new Set<string>();
4803
4861
  let anonymousSpanDepth = 0;
4804
- const ACTIVE_SPAN_FORCE_FLUSH_MS = 60000; // hard cutoff after finish to avoid endlessly running replies
4862
+ const ACTIVE_SPAN_FORCE_FLUSH_MS = 60000; // hard cutoff after finish to avoid endlessly running replies
4805
4863
 
4806
4864
  const clearTimers = () => {
4807
4865
  if (idleTimer) {
@@ -5086,6 +5144,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5086
5144
  t: requestEpochMs,
5087
5145
  }]
5088
5146
  });
5147
+ captureScope.backendRequestCount += 1;
5089
5148
  } catch {
5090
5149
  // never break user code
5091
5150
  }
@@ -5178,6 +5237,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5178
5237
  finished = true;
5179
5238
  if (finishedAt === null) {
5180
5239
  finishedAt = Date.now();
5240
+ finishedAtAligned = alignTimestamp(finishedAt);
5181
5241
  }
5182
5242
  if (capturedBody === undefined && chunks.length) {
5183
5243
  const buf = Buffer.isBuffer(chunks[0])
@@ -5190,47 +5250,63 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5190
5250
 
5191
5251
  if (!flushPayload) {
5192
5252
  flushPayload = async () => {
5193
- try {
5194
- const pendingEvents = preparePendingTraceEventsForFlush(events.slice());
5195
- const materializedEvents = await materializePendingTraceEventsAsync(pendingEvents, {
5196
- cfg,
5197
- maskReq,
5198
- masking,
5199
- resolvePrivacy,
5200
- });
5201
- const baseEvents = balanceTraceEvents(materializedEvents.slice());
5202
- const orderedEvents = TRACE_ORDER_MODE === 'tree'
5203
- ? reorderTraceEvents(baseEvents)
5204
- : sortTraceEventsChronologically(baseEvents);
5205
- const traceBatches = chunkTraceEventsForTransport(
5206
- orderedEvents,
5207
- rid,
5208
- aid,
5209
- );
5210
-
5211
- if (traceBatches.length) {
5212
- for (let i = 0; i < traceBatches.length; i++) {
5213
- const batch = traceBatches[i];
5214
- const traceValues = collectBatchTraceValueEntries(batch, i);
5215
-
5216
- post(cfg, sid, {
5217
- entries: [{
5218
- actionId: aid,
5219
- trace: batch,
5220
- traceValues: traceValues.length ? traceValues : undefined,
5221
- traceBatch: {
5222
- rid,
5223
- index: i,
5224
- total: traceBatches.length,
5225
- },
5226
- t: alignedNow(),
5227
- }],
5228
- });
5229
- }
5253
+ try {
5254
+ const pendingEvents = preparePendingTraceEventsForFlush(events.slice());
5255
+ const materializedEvents = await materializePendingTraceEventsAsync(pendingEvents, {
5256
+ cfg,
5257
+ maskReq,
5258
+ masking,
5259
+ resolvePrivacy,
5260
+ });
5261
+ const baseEvents = balanceTraceEvents(materializedEvents.slice());
5262
+ const orderedEvents = TRACE_ORDER_MODE === 'tree'
5263
+ ? reorderTraceEvents(baseEvents)
5264
+ : sortTraceEventsChronologically(baseEvents);
5265
+ const traceBatches = chunkTraceEventsForTransport(
5266
+ orderedEvents,
5267
+ rid,
5268
+ aid,
5269
+ );
5270
+
5271
+ if (traceBatches.length) {
5272
+ captureScope.traceBatchCount = traceBatches.length;
5273
+ for (let i = 0; i < traceBatches.length; i++) {
5274
+ const batch = traceBatches[i];
5275
+ const traceValues = collectBatchTraceValueEntries(batch, i);
5276
+
5277
+ post(cfg, sid, {
5278
+ entries: [{
5279
+ actionId: aid,
5280
+ trace: batch,
5281
+ traceValues: traceValues.length ? traceValues : undefined,
5282
+ traceBatch: {
5283
+ rid,
5284
+ index: i,
5285
+ total: traceBatches.length,
5286
+ },
5287
+ t: alignedNow(),
5288
+ }],
5289
+ });
5230
5290
  }
5231
- } catch {
5232
- // never break user code
5291
+ } else {
5292
+ captureScope.traceBatchCount = 0;
5233
5293
  }
5294
+
5295
+ post(cfg, sid, {
5296
+ entries: [{
5297
+ actionId: aid,
5298
+ requestRid: rid,
5299
+ requestCaptureManifest: buildRequestCaptureManifestPayload(
5300
+ captureScope,
5301
+ traceBatches.length,
5302
+ finishedAtAligned,
5303
+ ),
5304
+ t: finishedAtAligned ?? alignedNow(),
5305
+ }],
5306
+ });
5307
+ } catch {
5308
+ // never break user code
5309
+ }
5234
5310
  };
5235
5311
  }
5236
5312
 
@@ -5697,6 +5773,11 @@ function dehydrateComplexValue(value: any) {
5697
5773
 
5698
5774
  function emitDbQuery(cfg: any, sid?: string, aid?: string, payload?: any) {
5699
5775
  const ctx = getCtx() as Ctx;
5776
+ const spanContext = payload?.spanContext ?? captureSpanContextFromTracer();
5777
+ if (!shouldCaptureDbSpan(spanContext)) return;
5778
+ if (ctx.captureScope) {
5779
+ ctx.captureScope.dbChangeCount += 1;
5780
+ }
5700
5781
  const runCtx: Ctx = {
5701
5782
  sid: ctx.sid,
5702
5783
  aid: ctx.aid,
@@ -5704,6 +5785,7 @@ function emitDbQuery(cfg: any, sid?: string, aid?: string, payload?: any) {
5704
5785
  clockSkewMs: ctx.clockSkewMs,
5705
5786
  maskReq: ctx.maskReq,
5706
5787
  excludedSpanIds: ctx.excludedSpanIds ? new Set(ctx.excludedSpanIds) : undefined,
5788
+ captureScope: ctx.captureScope,
5707
5789
  };
5708
5790
  scheduleSdkBackgroundWork(async () => {
5709
5791
  await als.run(runCtx, async () => {
@@ -6588,9 +6670,10 @@ function scheduleKafkaTraceFlush(
6588
6670
  requestRid: string,
6589
6671
  rawTraceEvents: any[],
6590
6672
  maskReq: MaskRequestContext,
6673
+ captureScope?: RequestCaptureScopeState,
6591
6674
  ): void {
6592
6675
  const sid = kafkaCtx.sid;
6593
- if (!sid || !rawTraceEvents.length) return;
6676
+ if (!sid) return;
6594
6677
  const rawEvents = rawTraceEvents.slice();
6595
6678
 
6596
6679
  scheduleSdkBackgroundWork(async () => {
@@ -6611,9 +6694,25 @@ function scheduleKafkaTraceFlush(
6611
6694
  resolvePrivacy: () => getRuntimePrivacyState(cfg).policy ?? null,
6612
6695
  });
6613
6696
  const entries = buildKafkaTraceEntries(actionId, requestRid, materializedEvents);
6697
+ if (captureScope) {
6698
+ captureScope.traceBatchCount = entries.length;
6699
+ }
6614
6700
  if (entries.length) {
6615
6701
  post(cfg, sid, { entries });
6616
6702
  }
6703
+ if (captureScope) {
6704
+ post(cfg, sid, {
6705
+ entries: [{
6706
+ actionId: actionId ?? null,
6707
+ requestRid,
6708
+ requestCaptureManifest: buildRequestCaptureManifestPayload(
6709
+ captureScope,
6710
+ entries.length,
6711
+ ),
6712
+ t: alignedNow(),
6713
+ }],
6714
+ });
6715
+ }
6617
6716
  });
6618
6717
  }, { priority: 'high' });
6619
6718
  }
@@ -6631,6 +6730,7 @@ function patchKafkaProducerInstance(producer: any, cfg: KafkaJsPatchConfig) {
6631
6730
  const aid = ctx.aid ?? fallback.aid ?? null;
6632
6731
  const span = captureSpanContextFromTracer();
6633
6732
  const publishRid = nextRequestId(alignedNow());
6733
+ const captureScope = createRequestCaptureScope(publishRid, 'kafka');
6634
6734
  const payloadObj = payload && typeof payload === 'object' ? payload : {};
6635
6735
  const topic = sanitizeKafkaTopic((payloadObj as any).topic);
6636
6736
  const rawMessages = Array.isArray((payloadObj as any).messages) ? (payloadObj as any).messages : [];
@@ -6719,6 +6819,18 @@ function patchKafkaProducerInstance(producer: any, cfg: KafkaJsPatchConfig) {
6719
6819
  },
6720
6820
  ],
6721
6821
  });
6822
+ captureScope.backendRequestCount += 1;
6823
+ post(cfg, sid, {
6824
+ entries: [{
6825
+ actionId: aid ?? null,
6826
+ requestRid: publishRid,
6827
+ requestCaptureManifest: buildRequestCaptureManifestPayload(
6828
+ captureScope,
6829
+ 0,
6830
+ ),
6831
+ t: alignedNow(),
6832
+ }],
6833
+ });
6722
6834
  }
6723
6835
  };
6724
6836
  }
@@ -6732,6 +6844,7 @@ function patchKafkaProducerInstance(producer: any, cfg: KafkaJsPatchConfig) {
6732
6844
  const aid = ctx.aid ?? fallback.aid ?? null;
6733
6845
  const span = captureSpanContextFromTracer();
6734
6846
  const publishRid = nextRequestId(alignedNow());
6847
+ const captureScope = createRequestCaptureScope(publishRid, 'kafka');
6735
6848
 
6736
6849
  const payloadObj = payload && typeof payload === 'object' ? payload : {};
6737
6850
  const topicMessages = Array.isArray((payloadObj as any).topicMessages)
@@ -6854,6 +6967,18 @@ function patchKafkaProducerInstance(producer: any, cfg: KafkaJsPatchConfig) {
6854
6967
  },
6855
6968
  ],
6856
6969
  });
6970
+ captureScope.backendRequestCount += 1;
6971
+ post(cfg, sid, {
6972
+ entries: [{
6973
+ actionId: aid ?? null,
6974
+ requestRid: publishRid,
6975
+ requestCaptureManifest: buildRequestCaptureManifestPayload(
6976
+ captureScope,
6977
+ 0,
6978
+ ),
6979
+ t: alignedNow(),
6980
+ }],
6981
+ });
6857
6982
  }
6858
6983
  };
6859
6984
  }
@@ -6869,6 +6994,7 @@ function wrapKafkaEachMessageHandler(eachMessage: any, cfg: KafkaJsPatchConfig,
6869
6994
  const aid = runtime.aid;
6870
6995
  const consumeRid = nextRequestId(`${topic}-${message?.offset ?? alignedNow()}`);
6871
6996
  const traceId = consumeRid;
6997
+ const captureScope = createRequestCaptureScope(consumeRid, 'kafka');
6872
6998
  const rawTraceEvents: any[] = [];
6873
6999
  const maskReq: MaskRequestContext = {
6874
7000
  method: 'KAFKA_CONSUME',
@@ -6883,6 +7009,7 @@ function wrapKafkaEachMessageHandler(eachMessage: any, cfg: KafkaJsPatchConfig,
6883
7009
  excludedSpanIds: new Set<string>(),
6884
7010
  maskReq,
6885
7011
  privacyTasks: new Set<Promise<void>>(),
7012
+ captureScope,
6886
7013
  };
6887
7014
  let unsubscribe: undefined | (() => void);
6888
7015
 
@@ -6960,7 +7087,8 @@ function wrapKafkaEachMessageHandler(eachMessage: any, cfg: KafkaJsPatchConfig,
6960
7087
  t: alignedNow(),
6961
7088
  }],
6962
7089
  });
6963
- scheduleKafkaTraceFlush(cfg, kafkaCtx, aid, consumeRid, rawTraceEvents, maskReq);
7090
+ captureScope.backendRequestCount += 1;
7091
+ scheduleKafkaTraceFlush(cfg, kafkaCtx, aid, consumeRid, rawTraceEvents, maskReq, captureScope);
6964
7092
  }
6965
7093
  };
6966
7094
  }
@@ -6976,6 +7104,7 @@ function wrapKafkaEachBatchHandler(eachBatch: any, cfg: KafkaJsPatchConfig, cons
6976
7104
  const aid = runtime.aid;
6977
7105
  const consumeRid = nextRequestId(`${topic}-batch-${alignedNow()}`);
6978
7106
  const traceId = consumeRid;
7107
+ const captureScope = createRequestCaptureScope(consumeRid, 'kafka');
6979
7108
  const rawTraceEvents: any[] = [];
6980
7109
  const maskReq: MaskRequestContext = {
6981
7110
  method: 'KAFKA_CONSUME_BATCH',
@@ -6990,6 +7119,7 @@ function wrapKafkaEachBatchHandler(eachBatch: any, cfg: KafkaJsPatchConfig, cons
6990
7119
  excludedSpanIds: new Set<string>(),
6991
7120
  maskReq,
6992
7121
  privacyTasks: new Set<Promise<void>>(),
7122
+ captureScope,
6993
7123
  };
6994
7124
  let unsubscribe: undefined | (() => void);
6995
7125
 
@@ -7065,7 +7195,8 @@ function wrapKafkaEachBatchHandler(eachBatch: any, cfg: KafkaJsPatchConfig, cons
7065
7195
  t: alignedNow(),
7066
7196
  }],
7067
7197
  });
7068
- scheduleKafkaTraceFlush(cfg, kafkaCtx, aid, consumeRid, rawTraceEvents, maskReq);
7198
+ captureScope.backendRequestCount += 1;
7199
+ scheduleKafkaTraceFlush(cfg, kafkaCtx, aid, consumeRid, rawTraceEvents, maskReq, captureScope);
7069
7200
  }
7070
7201
  };
7071
7202
  }
@@ -7761,11 +7892,15 @@ function patchSendgridFromModuleExports(sgMail: any, cfg: SendgridPatchConfig) {
7761
7892
  const sid = ctx.sid ?? cfg.resolveContext?.()?.sid;
7762
7893
  const aid = ctx.aid ?? cfg.resolveContext?.()?.aid;
7763
7894
  if (!sid) return;
7895
+ if (ctx.captureScope) {
7896
+ ctx.captureScope.emailEventCount += 1;
7897
+ }
7764
7898
 
7765
7899
  const norm = normalizeSendgridMessage(rawMsg);
7766
7900
  post(cfg, sid, {
7767
7901
  entries: [{
7768
7902
  actionId: aid ?? null,
7903
+ requestRid: ctx.rid ?? null,
7769
7904
  email: {
7770
7905
  provider: 'sendgrid',
7771
7906
  kind,
@@ -13,6 +13,13 @@ const DEFAULT_QUEUE_MAX_ITEMS = (() => {
13
13
  const DEFAULT_QUEUE_MAX_ATTEMPTS = 5;
14
14
  const DEFAULT_QUEUE_BACKOFF_MAX_MS = 4000;
15
15
  const DEFAULT_DEFER_DRAIN_MS = 25;
16
+ const DEFAULT_INGEST_REQUEST_TIMEOUT_MS = (() => {
17
+ const env = Number(typeof process !== 'undefined' ? (process as any)?.env?.REPRO_INGEST_REQUEST_TIMEOUT_MS : undefined);
18
+ if (Number.isFinite(env) && env >= 0) {
19
+ return Math.trunc(env);
20
+ }
21
+ return 5000;
22
+ })();
16
23
 
17
24
  type QueuedIngestBatch = {
18
25
  config: IngestClientConfig;
@@ -91,16 +98,41 @@ const sendIngestEntries = async (
91
98
  return true;
92
99
  }
93
100
 
101
+ let timeout: ReturnType<typeof setTimeout> | null = null;
102
+ const Controller = (globalThis as any).AbortController;
103
+ const controller =
104
+ DEFAULT_INGEST_REQUEST_TIMEOUT_MS > 0 && typeof Controller === 'function'
105
+ ? new Controller()
106
+ : null;
107
+
94
108
  try {
109
+ if (controller) {
110
+ timeout = setTimeout(() => {
111
+ try {
112
+ controller.abort();
113
+ } catch {}
114
+ }, DEFAULT_INGEST_REQUEST_TIMEOUT_MS);
115
+ try {
116
+ (timeout as any).unref?.();
117
+ } catch {}
118
+ }
119
+
95
120
  const response = await fetch(resolveIngestUrl(config), {
96
121
  method: 'POST',
97
122
  headers: toHeaders(config),
98
123
  body: JSON.stringify({ events }),
124
+ ...(controller ? { signal: controller.signal } : {}),
99
125
  });
100
126
  return response.ok;
101
127
  } catch {
102
128
  // swallow in SDK
103
129
  return false;
130
+ } finally {
131
+ if (timeout) {
132
+ try {
133
+ clearTimeout(timeout);
134
+ } catch {}
135
+ }
104
136
  }
105
137
  };
106
138
 
@@ -7,6 +7,8 @@ const toIso = (timestamp: unknown): string => {
7
7
  };
8
8
 
9
9
  const inferEventType = (entry: LegacyEntry): string => {
10
+ if (entry.requestCaptureManifest !== undefined) return 'request_capture_manifest';
11
+ if (entry.sessionCaptureManifest !== undefined) return 'session_capture_manifest';
10
12
  if (entry.request !== undefined) return 'backend_request';
11
13
  if (entry.db !== undefined) return 'db_change';
12
14
  if (entry.trace !== undefined) return 'trace_batch';
@@ -18,6 +18,8 @@ export interface LegacyEntry {
18
18
  requestRid?: string | null;
19
19
  request?: unknown;
20
20
  requestValues?: unknown[];
21
+ requestCaptureManifest?: unknown;
22
+ sessionCaptureManifest?: unknown;
21
23
  db?: unknown;
22
24
  dbValues?: unknown[];
23
25
  trace?: unknown[];
@@ -0,0 +1,67 @@
1
+ process.env.REPRO_INGEST_REQUEST_TIMEOUT_MS = '25';
2
+
3
+ const assert = require('node:assert/strict');
4
+
5
+ const { postIngestEntries } = require('../dist/ingest/client');
6
+
7
+ async function main() {
8
+ const originalFetch = global.fetch;
9
+ let aborted = false;
10
+
11
+ global.fetch = async (_url, init = {}) => {
12
+ return await new Promise((_resolve, reject) => {
13
+ const signal = init.signal;
14
+ assert(signal, 'fetch should receive an AbortSignal');
15
+ const keepAlive = setTimeout(() => {}, 1000);
16
+ signal.addEventListener('abort', () => {
17
+ clearTimeout(keepAlive);
18
+ aborted = true;
19
+ const error = new Error('aborted');
20
+ error.name = 'AbortError';
21
+ reject(error);
22
+ });
23
+ });
24
+ };
25
+
26
+ try {
27
+ const startedAt = Date.now();
28
+ await postIngestEntries(
29
+ {
30
+ tenantId: 'TENANT_test',
31
+ appId: 'APP_test',
32
+ appSecret: 'secret',
33
+ ingestBase: 'http://127.0.0.1:65535',
34
+ },
35
+ 'S_ingest_timeout',
36
+ [
37
+ {
38
+ actionId: 'A_ingest_timeout',
39
+ request: {
40
+ rid: 'R_ingest_timeout',
41
+ method: 'GET',
42
+ url: '/timeout',
43
+ status: 200,
44
+ durMs: 1,
45
+ },
46
+ t: Date.now(),
47
+ },
48
+ ],
49
+ );
50
+ const elapsedMs = Date.now() - startedAt;
51
+
52
+ assert.equal(aborted, true, 'ingest fetch should be aborted on timeout');
53
+ assert(
54
+ elapsedMs < 500,
55
+ `ingest timeout should bound queue blocking; elapsed ${elapsedMs}ms`,
56
+ );
57
+
58
+ console.log('ingest timeout OK');
59
+ } finally {
60
+ global.fetch = originalFetch;
61
+ }
62
+ }
63
+
64
+ main().catch((error) => {
65
+ console.error(error);
66
+ process.exit(1);
67
+ });
@@ -74,6 +74,10 @@ async function main() {
74
74
  ingestBase: 'http://127.0.0.1:65535',
75
75
  };
76
76
  const { req, res } = makeTaggedReqRes('S_request_flush_timing', 'A_request_flush_timing');
77
+ let responseFinishedAt = 0;
78
+ res.on('finish', () => {
79
+ responseFinishedAt = Date.now();
80
+ });
77
81
 
78
82
  await new Promise((resolve, reject) => {
79
83
  void reproMiddleware(cfg)(req, res, async (err) => {
@@ -109,6 +113,19 @@ async function main() {
109
113
 
110
114
  assert.strictEqual(countEvents(capturedBodies, 'backend_request'), 1, JSON.stringify(capturedBodies));
111
115
  assert(countEvents(capturedBodies, 'trace_batch') >= 1, JSON.stringify(capturedBodies));
116
+ assert.strictEqual(
117
+ countEvents(capturedBodies, 'request_capture_manifest'),
118
+ 1,
119
+ JSON.stringify(capturedBodies),
120
+ );
121
+ const manifestEvent = flattenEvents(capturedBodies)
122
+ .find((event) => event?.event_type === 'request_capture_manifest');
123
+ const manifestClosedAt = Number(manifestEvent?.payload?.requestCaptureManifest?.closedAt);
124
+ assert(Number.isFinite(manifestClosedAt), JSON.stringify(manifestEvent));
125
+ assert(
126
+ Math.abs(manifestClosedAt - responseFinishedAt) < 500,
127
+ `manifest closedAt should use response finish time; got ${manifestClosedAt}, finish ${responseFinishedAt}`,
128
+ );
112
129
 
113
130
  console.log('request flush timing OK');
114
131
  } finally {