@reproapp/node-sdk 0.0.16 → 0.0.18

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
@@ -1513,10 +1513,10 @@ function resolveCollectionOrWarn(source, type) {
1513
1513
  }
1514
1514
  return name;
1515
1515
  }
1516
- function post(cfg, sessionId, body) {
1516
+ function post(cfg, sessionId, body, options) {
1517
1517
  try {
1518
1518
  const entries = Array.isArray(body?.entries) ? body.entries : [];
1519
- (0, client_1.enqueueIngestEntries)(cfg, sessionId, entries);
1519
+ (0, client_1.enqueueIngestEntries)(cfg, sessionId, entries, options);
1520
1520
  }
1521
1521
  catch { /* swallow in SDK */ }
1522
1522
  }
@@ -3969,6 +3969,7 @@ function reproMiddleware(cfg) {
3969
3969
  let hardStopTimer = null;
3970
3970
  let flushPayload = null;
3971
3971
  let requestCaptureScheduled = false;
3972
+ let requestCaptureManifestScheduled = false;
3972
3973
  let sessionDrainWait = null;
3973
3974
  const activeSpans = new Set();
3974
3975
  let anonymousSpanDepth = 0;
@@ -4232,6 +4233,19 @@ function reproMiddleware(cfg) {
4232
4233
  // never break user code
4233
4234
  }
4234
4235
  };
4236
+ const emitRequestCaptureManifest = (traceBatchTotal) => {
4237
+ if (requestCaptureManifestScheduled)
4238
+ return;
4239
+ requestCaptureManifestScheduled = true;
4240
+ post(cfg, sid, {
4241
+ entries: [{
4242
+ actionId: aid,
4243
+ requestRid: rid,
4244
+ requestCaptureManifest: buildRequestCaptureManifestPayload(captureScope, traceBatchTotal, finishedAtAligned),
4245
+ t: finishedAtAligned ?? alignedNow(),
4246
+ }],
4247
+ }, { priority: 'high' });
4248
+ };
4235
4249
  try {
4236
4250
  if (__TRACER__?.tracer?.on) {
4237
4251
  const getTid = __TRACER__?.getCurrentTraceId;
@@ -4328,6 +4342,7 @@ function reproMiddleware(cfg) {
4328
4342
  void emitRequestCaptureAsync();
4329
4343
  if (!flushPayload) {
4330
4344
  flushPayload = async () => {
4345
+ let traceBatches = [];
4331
4346
  try {
4332
4347
  const pendingEvents = preparePendingTraceEventsForFlush(events.slice());
4333
4348
  const materializedEvents = await materializePendingTraceEventsAsync(pendingEvents, {
@@ -4340,38 +4355,39 @@ function reproMiddleware(cfg) {
4340
4355
  const orderedEvents = TRACE_ORDER_MODE === 'tree'
4341
4356
  ? reorderTraceEvents(baseEvents)
4342
4357
  : sortTraceEventsChronologically(baseEvents);
4343
- const traceBatches = chunkTraceEventsForTransport(orderedEvents, rid, aid);
4358
+ traceBatches = chunkTraceEventsForTransport(orderedEvents, rid, aid);
4344
4359
  if (traceBatches.length) {
4345
4360
  captureScope.traceBatchCount = traceBatches.length;
4346
- for (let i = 0; i < traceBatches.length; i++) {
4347
- const batch = traceBatches[i];
4348
- const traceValues = collectBatchTraceValueEntries(batch, i);
4349
- post(cfg, sid, {
4350
- entries: [{
4351
- actionId: aid,
4352
- trace: batch,
4353
- traceValues: traceValues.length ? traceValues : undefined,
4354
- traceBatch: {
4355
- rid,
4356
- index: i,
4357
- total: traceBatches.length,
4358
- },
4359
- t: alignedNow(),
4360
- }],
4361
- });
4362
- }
4363
4361
  }
4364
4362
  else {
4365
4363
  captureScope.traceBatchCount = 0;
4366
4364
  }
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
- });
4365
+ }
4366
+ catch {
4367
+ captureScope.traceBatchCount = 0;
4368
+ traceBatches = [];
4369
+ }
4370
+ finally {
4371
+ emitRequestCaptureManifest(traceBatches.length);
4372
+ }
4373
+ try {
4374
+ for (let i = 0; i < traceBatches.length; i++) {
4375
+ const batch = traceBatches[i];
4376
+ const traceValues = collectBatchTraceValueEntries(batch, i);
4377
+ post(cfg, sid, {
4378
+ entries: [{
4379
+ actionId: aid,
4380
+ trace: batch,
4381
+ traceValues: traceValues.length ? traceValues : undefined,
4382
+ traceBatch: {
4383
+ rid,
4384
+ index: i,
4385
+ total: traceBatches.length,
4386
+ },
4387
+ t: alignedNow(),
4388
+ }],
4389
+ });
4390
+ }
4375
4391
  }
4376
4392
  catch {
4377
4393
  // never break user code
@@ -1,10 +1,13 @@
1
1
  import type { IngestClientConfig, LegacyEntry } from './types';
2
+ export type EnqueueIngestOptions = {
3
+ priority?: 'normal' | 'high';
4
+ };
2
5
  export declare const configureIngestQueueDrain: (options: {
3
6
  shouldDefer?: (() => boolean) | null;
4
7
  deferMs?: number;
5
8
  }) => void;
6
9
  export declare const kickIngestQueueDrain: () => void;
7
10
  export declare const postIngestEntries: (config: IngestClientConfig, sessionId: string, entries: LegacyEntry[]) => Promise<void>;
8
- export declare const enqueueIngestEntries: (config: IngestClientConfig, sessionId: string, entries: LegacyEntry[]) => void;
11
+ export declare const enqueueIngestEntries: (config: IngestClientConfig, sessionId: string, entries: LegacyEntry[], options?: EnqueueIngestOptions) => void;
9
12
  export declare const flushIngestQueue: () => Promise<void>;
10
13
  export type { IngestClientConfig, LegacyEntry } from './types';
@@ -171,18 +171,41 @@ const drainIngestQueue = async () => {
171
171
  }
172
172
  }
173
173
  };
174
- const enqueueIngestEntries = (config, sessionId, entries) => {
174
+ const enqueueIngestEntries = (config, sessionId, entries, options = {}) => {
175
175
  if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
176
176
  return;
177
177
  }
178
- ingestQueue.push({
178
+ const priority = options.priority === 'high' ? 'high' : 'normal';
179
+ const batch = {
179
180
  config: { ...config },
180
181
  sessionId,
181
182
  entries: entries.slice(),
182
183
  attempts: 0,
183
- });
184
+ priority,
185
+ };
186
+ if (priority === 'high') {
187
+ const firstNormalIndex = ingestQueue.findIndex((queued) => queued.priority !== 'high');
188
+ if (firstNormalIndex === -1) {
189
+ ingestQueue.push(batch);
190
+ }
191
+ else {
192
+ ingestQueue.splice(firstNormalIndex, 0, batch);
193
+ }
194
+ }
195
+ else {
196
+ ingestQueue.push(batch);
197
+ }
184
198
  if (DEFAULT_QUEUE_MAX_ITEMS > 0 && ingestQueue.length > DEFAULT_QUEUE_MAX_ITEMS) {
185
- ingestQueue.splice(0, ingestQueue.length - DEFAULT_QUEUE_MAX_ITEMS);
199
+ while (ingestQueue.length > DEFAULT_QUEUE_MAX_ITEMS) {
200
+ let removeIndex = ingestQueue.length - 1;
201
+ for (let index = ingestQueue.length - 1; index >= 0; index -= 1) {
202
+ if (ingestQueue[index]?.priority !== 'high') {
203
+ removeIndex = index;
204
+ break;
205
+ }
206
+ }
207
+ ingestQueue.splice(removeIndex, 1);
208
+ }
186
209
  }
187
210
  scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
188
211
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reproapp/node-sdk",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Repro Nest SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,8 @@
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 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"
15
+ "test": "npm run build && node test/unawaited.test.js && node test/integration-unawaited.js && node test/request-flush-timing.test.js && node test/request-manifest-priority.test.js && node test/runtime-no-double-invoke.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
+ "test:containers": "npm run build && node test/runtime-no-double-invoke.testcontainers.test.js"
16
17
  },
17
18
  "peerDependencies": {
18
19
  "express": "^5.1.0",
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  configureIngestQueueDrain,
10
10
  enqueueIngestEntries,
11
11
  kickIngestQueueDrain,
12
+ type EnqueueIngestOptions,
12
13
  type IngestClientConfig,
13
14
  type LegacyEntry,
14
15
  } from './ingest/client';
@@ -1908,10 +1909,11 @@ function post(
1908
1909
  cfg: IngestClientConfig,
1909
1910
  sessionId: string,
1910
1911
  body: any,
1912
+ options?: EnqueueIngestOptions,
1911
1913
  ) {
1912
1914
  try {
1913
1915
  const entries = Array.isArray(body?.entries) ? (body.entries as LegacyEntry[]) : [];
1914
- enqueueIngestEntries(cfg, sessionId, entries);
1916
+ enqueueIngestEntries(cfg, sessionId, entries, options);
1915
1917
  } catch { /* swallow in SDK */ }
1916
1918
  }
1917
1919
 
@@ -4856,6 +4858,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
4856
4858
  let hardStopTimer: NodeJS.Timeout | null = null;
4857
4859
  let flushPayload: null | (() => Promise<void>) = null;
4858
4860
  let requestCaptureScheduled = false;
4861
+ let requestCaptureManifestScheduled = false;
4859
4862
  let sessionDrainWait: Promise<void> | null = null;
4860
4863
  const activeSpans = new Set<string>();
4861
4864
  let anonymousSpanDepth = 0;
@@ -5150,6 +5153,23 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5150
5153
  }
5151
5154
  };
5152
5155
 
5156
+ const emitRequestCaptureManifest = (traceBatchTotal: number): void => {
5157
+ if (requestCaptureManifestScheduled) return;
5158
+ requestCaptureManifestScheduled = true;
5159
+ post(cfg, sid, {
5160
+ entries: [{
5161
+ actionId: aid,
5162
+ requestRid: rid,
5163
+ requestCaptureManifest: buildRequestCaptureManifestPayload(
5164
+ captureScope,
5165
+ traceBatchTotal,
5166
+ finishedAtAligned,
5167
+ ),
5168
+ t: finishedAtAligned ?? alignedNow(),
5169
+ }],
5170
+ }, { priority: 'high' });
5171
+ };
5172
+
5153
5173
  try {
5154
5174
  if (__TRACER__?.tracer?.on) {
5155
5175
  const getTid = __TRACER__?.getCurrentTraceId;
@@ -5250,6 +5270,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5250
5270
 
5251
5271
  if (!flushPayload) {
5252
5272
  flushPayload = async () => {
5273
+ let traceBatches: TraceEventRecord[][] = [];
5253
5274
  try {
5254
5275
  const pendingEvents = preparePendingTraceEventsForFlush(events.slice());
5255
5276
  const materializedEvents = await materializePendingTraceEventsAsync(pendingEvents, {
@@ -5262,7 +5283,7 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5262
5283
  const orderedEvents = TRACE_ORDER_MODE === 'tree'
5263
5284
  ? reorderTraceEvents(baseEvents)
5264
5285
  : sortTraceEventsChronologically(baseEvents);
5265
- const traceBatches = chunkTraceEventsForTransport(
5286
+ traceBatches = chunkTraceEventsForTransport(
5266
5287
  orderedEvents,
5267
5288
  rid,
5268
5289
  aid,
@@ -5270,40 +5291,36 @@ export function reproMiddleware(cfg: ReproMiddlewareConfig) {
5270
5291
 
5271
5292
  if (traceBatches.length) {
5272
5293
  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
5294
  } else {
5292
5295
  captureScope.traceBatchCount = 0;
5293
5296
  }
5294
5297
 
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
- });
5298
+ } catch {
5299
+ captureScope.traceBatchCount = 0;
5300
+ traceBatches = [];
5301
+ } finally {
5302
+ emitRequestCaptureManifest(traceBatches.length);
5303
+ }
5304
+
5305
+ try {
5306
+ for (let i = 0; i < traceBatches.length; i++) {
5307
+ const batch = traceBatches[i];
5308
+ const traceValues = collectBatchTraceValueEntries(batch, i);
5309
+
5310
+ post(cfg, sid, {
5311
+ entries: [{
5312
+ actionId: aid,
5313
+ trace: batch,
5314
+ traceValues: traceValues.length ? traceValues : undefined,
5315
+ traceBatch: {
5316
+ rid,
5317
+ index: i,
5318
+ total: traceBatches.length,
5319
+ },
5320
+ t: alignedNow(),
5321
+ }],
5322
+ });
5323
+ }
5307
5324
  } catch {
5308
5325
  // never break user code
5309
5326
  }
@@ -26,6 +26,11 @@ type QueuedIngestBatch = {
26
26
  sessionId: string;
27
27
  entries: LegacyEntry[];
28
28
  attempts: number;
29
+ priority: 'normal' | 'high';
30
+ };
31
+
32
+ export type EnqueueIngestOptions = {
33
+ priority?: 'normal' | 'high';
29
34
  };
30
35
 
31
36
  const ingestQueue: QueuedIngestBatch[] = [];
@@ -205,18 +210,40 @@ export const enqueueIngestEntries = (
205
210
  config: IngestClientConfig,
206
211
  sessionId: string,
207
212
  entries: LegacyEntry[],
213
+ options: EnqueueIngestOptions = {},
208
214
  ): void => {
209
215
  if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
210
216
  return;
211
217
  }
212
- ingestQueue.push({
218
+ const priority = options.priority === 'high' ? 'high' : 'normal';
219
+ const batch: QueuedIngestBatch = {
213
220
  config: { ...config },
214
221
  sessionId,
215
222
  entries: entries.slice(),
216
223
  attempts: 0,
217
- });
224
+ priority,
225
+ };
226
+ if (priority === 'high') {
227
+ const firstNormalIndex = ingestQueue.findIndex((queued) => queued.priority !== 'high');
228
+ if (firstNormalIndex === -1) {
229
+ ingestQueue.push(batch);
230
+ } else {
231
+ ingestQueue.splice(firstNormalIndex, 0, batch);
232
+ }
233
+ } else {
234
+ ingestQueue.push(batch);
235
+ }
218
236
  if (DEFAULT_QUEUE_MAX_ITEMS > 0 && ingestQueue.length > DEFAULT_QUEUE_MAX_ITEMS) {
219
- ingestQueue.splice(0, ingestQueue.length - DEFAULT_QUEUE_MAX_ITEMS);
237
+ while (ingestQueue.length > DEFAULT_QUEUE_MAX_ITEMS) {
238
+ let removeIndex = ingestQueue.length - 1;
239
+ for (let index = ingestQueue.length - 1; index >= 0; index -= 1) {
240
+ if (ingestQueue[index]?.priority !== 'high') {
241
+ removeIndex = index;
242
+ break;
243
+ }
244
+ }
245
+ ingestQueue.splice(removeIndex, 1);
246
+ }
220
247
  }
221
248
  scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
222
249
  };
@@ -0,0 +1,147 @@
1
+ process.env.REPRO_INGEST_REQUEST_TIMEOUT_MS = '50';
2
+ process.env.REPRO_SDK_BACKGROUND_MAX_DEFER_MS = '25';
3
+ process.env.REPRO_SDK_BACKGROUND_IDLE_DELAY_MS = '0';
4
+ process.env.REPRO_SDK_BACKGROUND_BUSY_IDLE_DELAY_MS = '0';
5
+ process.env.TRACE_IDLE_FLUSH_MS = '10';
6
+ process.env.TRACE_LINGER_AFTER_FINISH_MS = '0';
7
+
8
+ const assert = require('assert');
9
+ const { EventEmitter } = require('events');
10
+ const { initReproTracing, reproMiddleware } = require('../dist');
11
+ const { flushIngestQueue } = require('../dist/ingest/client');
12
+
13
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+
15
+ async function waitFor(predicate, timeoutMs = 1500) {
16
+ const deadline = Date.now() + timeoutMs;
17
+ while (Date.now() < deadline) {
18
+ if (predicate()) return;
19
+ await sleep(20);
20
+ }
21
+ assert(predicate(), 'timed out waiting for expected condition');
22
+ }
23
+
24
+ function makeTaggedReqRes(sessionId, actionId) {
25
+ const req = new EventEmitter();
26
+ req.method = 'GET';
27
+ req.url = '/manifest-priority';
28
+ req.headers = {
29
+ 'x-bug-session-id': sessionId,
30
+ 'x-bug-action-id': actionId,
31
+ 'x-bug-request-start': String(Date.now()),
32
+ };
33
+ req.body = undefined;
34
+ req.params = {};
35
+ req.query = {};
36
+
37
+ const res = new EventEmitter();
38
+ res.statusCode = 200;
39
+ res.getHeader = () => undefined;
40
+ res.setHeader = () => {};
41
+ res.json = function (body) { this.body = body; this.emit('finish'); return body; };
42
+ res.send = function (body) { this.body = body; this.emit('finish'); return body; };
43
+ res.write = () => true;
44
+ res.end = () => { res.emit('finish'); return true; };
45
+
46
+ return { req, res };
47
+ }
48
+
49
+ function flattenEvents(posts) {
50
+ return posts.flatMap((body) => Array.isArray(body?.events) ? body.events : []);
51
+ }
52
+
53
+ async function tracedHandler() {
54
+ return {
55
+ ok: true,
56
+ payload: 'x'.repeat(16_000),
57
+ };
58
+ }
59
+
60
+ async function main() {
61
+ const capturedBodies = [];
62
+ const originalFetch = global.fetch;
63
+ global.fetch = async (_url, init = {}) => {
64
+ const body = JSON.parse(String(init.body || '{}'));
65
+ capturedBodies.push(body);
66
+ const hasTraceBatch = Array.isArray(body.events) &&
67
+ body.events.some((event) => event?.event_type === 'trace_batch');
68
+ if (hasTraceBatch) {
69
+ return new Promise((_resolve, reject) => {
70
+ const signal = init.signal;
71
+ if (signal?.aborted) {
72
+ reject(new Error('aborted'));
73
+ return;
74
+ }
75
+ signal?.addEventListener?.('abort', () => reject(new Error('aborted')), { once: true });
76
+ });
77
+ }
78
+ return {
79
+ ok: true,
80
+ status: 202,
81
+ json: async () => ({ ok: true }),
82
+ text: async () => '{"ok":true}',
83
+ };
84
+ };
85
+
86
+ try {
87
+ initReproTracing({ instrument: false, logFunctionCalls: false });
88
+ const cfg = {
89
+ tenantId: 'TENANT_test',
90
+ appId: 'APP_test',
91
+ appSecret: 'secret',
92
+ captureHeaders: false,
93
+ privacy: { environment: 'dev' },
94
+ ingestBase: 'http://127.0.0.1:65535',
95
+ };
96
+ const { req, res } = makeTaggedReqRes('S_manifest_priority', 'A_manifest_priority');
97
+
98
+ await new Promise((resolve, reject) => {
99
+ void reproMiddleware(cfg)(req, res, async (err) => {
100
+ if (err) {
101
+ reject(err);
102
+ return;
103
+ }
104
+
105
+ try {
106
+ await global.__repro_call(
107
+ tracedHandler,
108
+ null,
109
+ [],
110
+ 'app',
111
+ 1,
112
+ 'tracedHandler',
113
+ true,
114
+ );
115
+ res.json({ ok: true });
116
+ resolve();
117
+ } catch (error) {
118
+ reject(error);
119
+ }
120
+ });
121
+ });
122
+
123
+ await waitFor(() => flattenEvents(capturedBodies)
124
+ .some((event) => event?.event_type === 'request_capture_manifest'));
125
+
126
+ const eventTypes = flattenEvents(capturedBodies).map((event) => event?.event_type);
127
+ const manifestIndex = eventTypes.indexOf('request_capture_manifest');
128
+ const traceIndex = eventTypes.indexOf('trace_batch');
129
+
130
+ assert(manifestIndex >= 0, JSON.stringify(eventTypes));
131
+ assert(traceIndex >= 0, JSON.stringify(eventTypes));
132
+ assert(
133
+ manifestIndex < traceIndex,
134
+ `request_capture_manifest must be sent before trace_batch; got ${JSON.stringify(eventTypes)}`,
135
+ );
136
+
137
+ console.log('request manifest priority OK');
138
+ } finally {
139
+ global.fetch = originalFetch;
140
+ await flushIngestQueue();
141
+ }
142
+ }
143
+
144
+ main().catch((error) => {
145
+ console.error(error);
146
+ process.exitCode = 1;
147
+ });
@@ -0,0 +1,68 @@
1
+ const assert = require('assert');
2
+
3
+ const { trace, SYM_BODY_TRACED } = require('../tracer/runtime');
4
+
5
+ function assertSyncThrowsOnceWithNoActiveTrace() {
6
+ let calls = 0;
7
+ function throwsOnce() {
8
+ calls += 1;
9
+ throw new Error('app failure');
10
+ }
11
+
12
+ assert.throws(
13
+ () => global.__repro_call(throwsOnce, null, [], __filename, 10, 'throwsOnce', false),
14
+ /app failure/,
15
+ );
16
+ assert.strictEqual(calls, 1);
17
+ }
18
+
19
+ function assertSyncThrowsOnceWithActiveTrace() {
20
+ let calls = 0;
21
+ function throwsOnce() {
22
+ calls += 1;
23
+ throw new Error('traced app failure');
24
+ }
25
+
26
+ assert.throws(
27
+ () => trace.withTrace('runtime-no-double-invoke', () => (
28
+ global.__repro_call(throwsOnce, null, [], __filename, 25, 'throwsOnce', false)
29
+ )),
30
+ /traced app failure/,
31
+ );
32
+ assert.strictEqual(calls, 1);
33
+ }
34
+
35
+ function assertBodyTracedThrowsOnceWithActiveTrace() {
36
+ let calls = 0;
37
+ function bodyTracedThrowsOnce() {
38
+ calls += 1;
39
+ throw new Error('body traced app failure');
40
+ }
41
+ Object.defineProperty(bodyTracedThrowsOnce, SYM_BODY_TRACED, {
42
+ value: true,
43
+ configurable: true,
44
+ });
45
+
46
+ assert.throws(
47
+ () => trace.withTrace('runtime-no-double-invoke-body', () => (
48
+ global.__repro_call(bodyTracedThrowsOnce, null, [], __filename, 45, 'bodyTracedThrowsOnce', false)
49
+ )),
50
+ /body traced app failure/,
51
+ );
52
+ assert.strictEqual(calls, 1);
53
+ }
54
+
55
+ async function main() {
56
+ assertSyncThrowsOnceWithNoActiveTrace();
57
+ assertSyncThrowsOnceWithActiveTrace();
58
+ assertBodyTracedThrowsOnceWithActiveTrace();
59
+
60
+ // eslint-disable-next-line no-console
61
+ console.log('runtime no double invoke OK');
62
+ }
63
+
64
+ main().catch((error) => {
65
+ // eslint-disable-next-line no-console
66
+ console.error(error);
67
+ process.exitCode = 1;
68
+ });
@@ -0,0 +1,70 @@
1
+ const assert = require('assert');
2
+ const mongoose = require('mongoose');
3
+ const { GenericContainer, Wait } = require('testcontainers');
4
+
5
+ const { trace } = require('../tracer/runtime');
6
+
7
+ async function startMongo() {
8
+ return new GenericContainer('mongo:7')
9
+ .withExposedPorts(27017)
10
+ .withTmpFs({ '/data/db': 'rw,noexec,nosuid,size=256m' })
11
+ .withCommand(['mongod', '--bind_ip_all', '--setParameter', 'diagnosticDataCollectionEnabled=false'])
12
+ .withWaitStrategy(Wait.forListeningPorts())
13
+ .start();
14
+ }
15
+
16
+ async function main() {
17
+ let container;
18
+ let connection;
19
+
20
+ try {
21
+ container = await startMongo();
22
+ const uri = `mongodb://${container.getHost()}:${container.getMappedPort(27017)}/repro_node_sdk_test`;
23
+ connection = await mongoose.createConnection(uri).asPromise();
24
+
25
+ const SideEffect = connection.model('SideEffect', new mongoose.Schema({
26
+ name: { type: String, required: true },
27
+ }));
28
+
29
+ let calls = 0;
30
+ async function writeThenThrow(name) {
31
+ calls += 1;
32
+ await SideEffect.create({ name });
33
+ throw new Error('intentional app failure');
34
+ }
35
+
36
+ await assert.rejects(
37
+ () => trace.withTrace('runtime-no-double-invoke-container', () => (
38
+ global.__repro_call(
39
+ writeThenThrow,
40
+ null,
41
+ ['single-write'],
42
+ __filename,
43
+ 35,
44
+ 'writeThenThrow',
45
+ false,
46
+ )
47
+ )),
48
+ /intentional app failure/,
49
+ );
50
+
51
+ assert.strictEqual(calls, 1);
52
+ assert.strictEqual(await SideEffect.countDocuments({ name: 'single-write' }), 1);
53
+
54
+ // eslint-disable-next-line no-console
55
+ console.log('runtime no double invoke testcontainers OK');
56
+ } finally {
57
+ if (connection) {
58
+ await connection.close().catch(() => {});
59
+ }
60
+ if (container) {
61
+ await container.stop().catch(() => {});
62
+ }
63
+ }
64
+ }
65
+
66
+ main().catch((error) => {
67
+ // eslint-disable-next-line no-console
68
+ console.error(error);
69
+ process.exitCode = 1;
70
+ });
package/tracer/runtime.js CHANGED
@@ -505,9 +505,14 @@ function forkAlsStoreForUnawaited(baseStore) {
505
505
  if (!global.__repro_call) {
506
506
  Object.defineProperty(global, '__repro_call', {
507
507
  value: function __repro_call(fn, thisArg, args, callFile, callLine, label, isUnawaitedCall) {
508
+ let didInvokeTarget = false;
509
+ const invokeTarget = (targetThis, targetArgs) => {
510
+ didInvokeTarget = true;
511
+ return fn.apply(targetThis, targetArgs);
512
+ };
508
513
  try {
509
514
  if (typeof fn !== 'function' || fn[SYM_SKIP_WRAP]) {
510
- return fn.apply(thisArg, args);
515
+ return invokeTarget(thisArg, args);
511
516
  }
512
517
 
513
518
  const currentStore = als.getStore();
@@ -516,13 +521,9 @@ if (!global.__repro_call) {
516
521
  // If no tracing context is active, bail out quickly to avoid touching app semantics
517
522
  // during module initialization or other untracked code paths.
518
523
  if (!currentStore) {
519
- try {
520
- const out = fn.apply(thisArg, args);
521
- if (isUnawaitedCall && isThenable(out)) markPromiseUnawaited(out);
522
- return out;
523
- } catch {
524
- return fn ? fn.apply(thisArg, args) : undefined;
525
- }
524
+ const out = invokeTarget(thisArg, args);
525
+ if (isUnawaitedCall && isThenable(out)) markPromiseUnawaited(out);
526
+ return out;
526
527
  }
527
528
 
528
529
  const sourceFile = fn[SYM_SRC_FILE];
@@ -643,7 +644,7 @@ if (!global.__repro_call) {
643
644
  const shouldWrap = !isAsyncLocalContextSetter(fn, thisArg);
644
645
  const argsForCall = shouldWrap ? wrapArgsWithStore(args, snapshotForArgs) : args;
645
646
  try {
646
- const out = fn.apply(thisArg, argsForCall);
647
+ const out = invokeTarget(thisArg, argsForCall);
647
648
 
648
649
  const isThenableValue = isThenable(out);
649
650
  const isQuery = isMongooseQuery(out);
@@ -755,10 +756,10 @@ if (!global.__repro_call) {
755
756
  let out;
756
757
  if (isolatedStore) {
757
758
  als.run(isolatedStore, () => {
758
- out = fn.apply(thisArg, argsForCall);
759
+ out = invokeTarget(thisArg, argsForCall);
759
760
  });
760
761
  } else {
761
- out = fn.apply(thisArg, argsForCall);
762
+ out = invokeTarget(thisArg, argsForCall);
762
763
  }
763
764
  if (isUnawaitedCall && isThenable(out)) markPromiseUnawaited(out);
764
765
  return out;
@@ -775,7 +776,10 @@ if (!global.__repro_call) {
775
776
  }
776
777
  return invokeWithTrace(forcedParentSpanId, callStoreForArgs);
777
778
  });
778
- } catch {
779
+ } catch (error) {
780
+ if (didInvokeTarget) {
781
+ throw error;
782
+ }
779
783
  return fn ? fn.apply(thisArg, args) : undefined;
780
784
  }
781
785
  },