@reproapp/node-sdk 0.0.14 → 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
@@ -793,8 +793,11 @@ function createRequestCaptureScope(requestRid, transport) {
793
793
  emailEventCount: 0,
794
794
  };
795
795
  }
796
- function buildRequestCaptureManifestPayload(scope, traceBatchTotal) {
796
+ function buildRequestCaptureManifestPayload(scope, traceBatchTotal, closedAtMs) {
797
797
  const expectedTraceBatches = Math.max(0, Math.trunc(traceBatchTotal));
798
+ const closedAt = typeof closedAtMs === 'number' && Number.isFinite(closedAtMs)
799
+ ? closedAtMs
800
+ : alignedNow();
798
801
  return {
799
802
  scopeId: `req:${scope.requestRid}`,
800
803
  requestRid: scope.requestRid,
@@ -811,7 +814,7 @@ function buildRequestCaptureManifestPayload(scope, traceBatchTotal) {
811
814
  to: expectedTraceBatches - 1,
812
815
  }
813
816
  : undefined,
814
- closedAt: alignedNow(),
817
+ closedAt,
815
818
  };
816
819
  }
817
820
  const als = new async_hooks_1.AsyncLocalStorage();
@@ -3960,6 +3963,7 @@ function reproMiddleware(cfg) {
3960
3963
  let flushed = false;
3961
3964
  let finished = false;
3962
3965
  let finishedAt = null;
3966
+ let finishedAtAligned = null;
3963
3967
  let lastEventAt = Date.now();
3964
3968
  let idleTimer = null;
3965
3969
  let hardStopTimer = null;
@@ -4313,6 +4317,7 @@ function reproMiddleware(cfg) {
4313
4317
  finished = true;
4314
4318
  if (finishedAt === null) {
4315
4319
  finishedAt = Date.now();
4320
+ finishedAtAligned = alignTimestamp(finishedAt);
4316
4321
  }
4317
4322
  if (capturedBody === undefined && chunks.length) {
4318
4323
  const buf = Buffer.isBuffer(chunks[0])
@@ -4363,8 +4368,8 @@ function reproMiddleware(cfg) {
4363
4368
  entries: [{
4364
4369
  actionId: aid,
4365
4370
  requestRid: rid,
4366
- requestCaptureManifest: buildRequestCaptureManifestPayload(captureScope, traceBatches.length),
4367
- t: alignedNow(),
4371
+ requestCaptureManifest: buildRequestCaptureManifestPayload(captureScope, traceBatches.length, finishedAtAligned),
4372
+ t: finishedAtAligned ?? alignedNow(),
4368
4373
  }],
4369
4374
  });
4370
4375
  }
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reproapp/node-sdk",
3
- "version": "0.0.14",
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
@@ -1181,8 +1181,13 @@ function createRequestCaptureScope(
1181
1181
  function buildRequestCaptureManifestPayload(
1182
1182
  scope: RequestCaptureScopeState,
1183
1183
  traceBatchTotal: number,
1184
+ closedAtMs?: number | null,
1184
1185
  ): Record<string, any> {
1185
1186
  const expectedTraceBatches = Math.max(0, Math.trunc(traceBatchTotal));
1187
+ const closedAt =
1188
+ typeof closedAtMs === 'number' && Number.isFinite(closedAtMs)
1189
+ ? closedAtMs
1190
+ : alignedNow();
1186
1191
  return {
1187
1192
  scopeId: `req:${scope.requestRid}`,
1188
1193
  requestRid: scope.requestRid,
@@ -1200,7 +1205,7 @@ function buildRequestCaptureManifestPayload(
1200
1205
  to: expectedTraceBatches - 1,
1201
1206
  }
1202
1207
  : undefined,
1203
- closedAt: alignedNow(),
1208
+ closedAt,
1204
1209
  };
1205
1210
  }
1206
1211
 
@@ -4836,7 +4841,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
4836
4841
  return fn();
4837
4842
  };
4838
4843
 
4839
- runInTrace(() => als.run({ sid, aid, rid, clockSkewMs, excludedSpanIds: new Set<string>(), maskReq, captureScope }, () => {
4844
+ runInTrace(() => als.run({ sid, aid, rid, clockSkewMs, excludedSpanIds: new Set<string>(), maskReq, captureScope }, () => {
4840
4845
  const events: PendingTraceEventRecord[] = [];
4841
4846
  let endpointTrace: EndpointTraceInfo | null = null;
4842
4847
  let preferredAppTrace: EndpointTraceInfo | null = null;
@@ -4845,15 +4850,16 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
4845
4850
  let flushed = false;
4846
4851
  let finished = false;
4847
4852
  let finishedAt: number | null = null;
4853
+ let finishedAtAligned: number | null = null;
4848
4854
  let lastEventAt: number = Date.now();
4849
4855
  let idleTimer: NodeJS.Timeout | null = null;
4850
- let hardStopTimer: NodeJS.Timeout | null = null;
4856
+ let hardStopTimer: NodeJS.Timeout | null = null;
4851
4857
  let flushPayload: null | (() => Promise<void>) = null;
4852
4858
  let requestCaptureScheduled = false;
4853
4859
  let sessionDrainWait: Promise<void> | null = null;
4854
4860
  const activeSpans = new Set<string>();
4855
4861
  let anonymousSpanDepth = 0;
4856
- 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
4857
4863
 
4858
4864
  const clearTimers = () => {
4859
4865
  if (idleTimer) {
@@ -5231,6 +5237,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5231
5237
  finished = true;
5232
5238
  if (finishedAt === null) {
5233
5239
  finishedAt = Date.now();
5240
+ finishedAtAligned = alignTimestamp(finishedAt);
5234
5241
  }
5235
5242
  if (capturedBody === undefined && chunks.length) {
5236
5243
  const buf = Buffer.isBuffer(chunks[0])
@@ -5243,62 +5250,63 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5243
5250
 
5244
5251
  if (!flushPayload) {
5245
5252
  flushPayload = async () => {
5246
- try {
5247
- const pendingEvents = preparePendingTraceEventsForFlush(events.slice());
5248
- const materializedEvents = await materializePendingTraceEventsAsync(pendingEvents, {
5249
- cfg,
5250
- maskReq,
5251
- masking,
5252
- resolvePrivacy,
5253
- });
5254
- const baseEvents = balanceTraceEvents(materializedEvents.slice());
5255
- const orderedEvents = TRACE_ORDER_MODE === 'tree'
5256
- ? reorderTraceEvents(baseEvents)
5257
- : sortTraceEventsChronologically(baseEvents);
5258
- const traceBatches = chunkTraceEventsForTransport(
5259
- orderedEvents,
5260
- rid,
5261
- aid,
5262
- );
5263
-
5264
- if (traceBatches.length) {
5265
- captureScope.traceBatchCount = traceBatches.length;
5266
- for (let i = 0; i < traceBatches.length; i++) {
5267
- const batch = traceBatches[i];
5268
- const traceValues = collectBatchTraceValueEntries(batch, i);
5269
-
5270
- post(cfg, sid, {
5271
- entries: [{
5272
- actionId: aid,
5273
- trace: batch,
5274
- traceValues: traceValues.length ? traceValues : undefined,
5275
- traceBatch: {
5276
- rid,
5277
- index: i,
5278
- total: traceBatches.length,
5279
- },
5280
- t: alignedNow(),
5281
- }],
5282
- });
5283
- }
5284
- } else {
5285
- captureScope.traceBatchCount = 0;
5286
- }
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
+ );
5287
5270
 
5288
- post(cfg, sid, {
5289
- entries: [{
5290
- actionId: aid,
5291
- requestRid: rid,
5292
- requestCaptureManifest: buildRequestCaptureManifestPayload(
5293
- captureScope,
5294
- traceBatches.length,
5295
- ),
5296
- t: alignedNow(),
5297
- }],
5298
- });
5299
- } catch {
5300
- // never break user code
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
+ });
5290
+ }
5291
+ } else {
5292
+ captureScope.traceBatchCount = 0;
5301
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
+ }
5302
5310
  };
5303
5311
  }
5304
5312
 
@@ -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
 
@@ -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) => {
@@ -114,6 +118,14 @@ async function main() {
114
118
  1,
115
119
  JSON.stringify(capturedBodies),
116
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
+ );
117
129
 
118
130
  console.log('request flush timing OK');
119
131
  } finally {