@reprova/sdk 0.4.0 → 0.6.0

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.
Files changed (147) hide show
  1. package/README.md +170 -8
  2. package/dist/cjs/context.js +28 -0
  3. package/dist/cjs/distributionBaselinePoller.js +87 -0
  4. package/dist/cjs/distributionDrift.js +170 -0
  5. package/dist/cjs/drizzle.js +204 -0
  6. package/dist/cjs/frameworkAdapter.js +2 -0
  7. package/dist/cjs/httpFetch.js +59 -0
  8. package/dist/cjs/idempotency.js +132 -0
  9. package/dist/cjs/idempotencySettingsPoller.js +71 -0
  10. package/dist/cjs/index.js +96 -0
  11. package/dist/cjs/invariantInference.js +90 -0
  12. package/dist/cjs/invariantRules.js +142 -0
  13. package/dist/cjs/knex.js +86 -0
  14. package/dist/cjs/kysely.js +175 -0
  15. package/dist/cjs/legacyPrisma.js +130 -0
  16. package/dist/cjs/mikroorm.js +74 -0
  17. package/dist/cjs/mongoSchemaReporter.js +85 -0
  18. package/dist/cjs/mongoose.js +335 -0
  19. package/dist/cjs/nPlusOne.js +0 -0
  20. package/dist/cjs/nest.js +136 -0
  21. package/dist/cjs/nextjs.js +180 -0
  22. package/dist/cjs/package.json +1 -0
  23. package/dist/cjs/prisma.js +363 -0
  24. package/dist/cjs/proto.gen.js +4 -0
  25. package/dist/cjs/registry.js +100 -0
  26. package/dist/cjs/retryStorm.js +70 -0
  27. package/dist/cjs/rulesPoller.js +77 -0
  28. package/dist/cjs/sdk.js +1231 -0
  29. package/dist/cjs/sequelize.js +108 -0
  30. package/dist/cjs/shape.js +45 -0
  31. package/dist/cjs/stack.js +51 -0
  32. package/dist/cjs/transport.js +71 -0
  33. package/dist/cjs/typeorm.js +200 -0
  34. package/dist/context.d.ts +32 -0
  35. package/dist/context.d.ts.map +1 -1
  36. package/dist/context.js +18 -0
  37. package/dist/context.js.map +1 -1
  38. package/dist/distributionBaselinePoller.d.ts +36 -0
  39. package/dist/distributionBaselinePoller.d.ts.map +1 -0
  40. package/dist/distributionBaselinePoller.js +84 -0
  41. package/dist/distributionBaselinePoller.js.map +1 -0
  42. package/dist/distributionDrift.d.ts +41 -0
  43. package/dist/distributionDrift.d.ts.map +1 -0
  44. package/dist/distributionDrift.js +161 -0
  45. package/dist/distributionDrift.js.map +1 -0
  46. package/dist/drizzle.d.ts +22 -0
  47. package/dist/drizzle.d.ts.map +1 -1
  48. package/dist/drizzle.js +129 -1
  49. package/dist/drizzle.js.map +1 -1
  50. package/dist/frameworkAdapter.d.ts +5 -0
  51. package/dist/frameworkAdapter.d.ts.map +1 -0
  52. package/dist/frameworkAdapter.js +2 -0
  53. package/dist/frameworkAdapter.js.map +1 -0
  54. package/dist/httpFetch.d.ts +13 -0
  55. package/dist/httpFetch.d.ts.map +1 -0
  56. package/dist/httpFetch.js +54 -0
  57. package/dist/httpFetch.js.map +1 -0
  58. package/dist/idempotency.d.ts +26 -0
  59. package/dist/idempotency.d.ts.map +1 -0
  60. package/dist/idempotency.js +124 -0
  61. package/dist/idempotency.js.map +1 -0
  62. package/dist/idempotencySettingsPoller.d.ts +23 -0
  63. package/dist/idempotencySettingsPoller.d.ts.map +1 -0
  64. package/dist/idempotencySettingsPoller.js +68 -0
  65. package/dist/idempotencySettingsPoller.js.map +1 -0
  66. package/dist/index.d.ts +33 -6
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +19 -4
  69. package/dist/index.js.map +1 -1
  70. package/dist/invariantInference.d.ts +28 -0
  71. package/dist/invariantInference.d.ts.map +1 -0
  72. package/dist/invariantInference.js +87 -0
  73. package/dist/invariantInference.js.map +1 -0
  74. package/dist/invariantRules.d.ts +16 -0
  75. package/dist/invariantRules.d.ts.map +1 -0
  76. package/dist/invariantRules.js +104 -0
  77. package/dist/invariantRules.js.map +1 -0
  78. package/dist/knex.d.ts.map +1 -1
  79. package/dist/knex.js +1 -0
  80. package/dist/knex.js.map +1 -1
  81. package/dist/kysely.d.ts.map +1 -1
  82. package/dist/kysely.js +2 -1
  83. package/dist/kysely.js.map +1 -1
  84. package/dist/legacyPrisma.d.ts +6 -0
  85. package/dist/legacyPrisma.d.ts.map +1 -0
  86. package/dist/legacyPrisma.js +128 -0
  87. package/dist/legacyPrisma.js.map +1 -0
  88. package/dist/mikroorm.d.ts +2 -0
  89. package/dist/mikroorm.d.ts.map +1 -0
  90. package/dist/mikroorm.js +72 -0
  91. package/dist/mikroorm.js.map +1 -0
  92. package/dist/mongoSchemaReporter.d.ts +23 -0
  93. package/dist/mongoSchemaReporter.d.ts.map +1 -0
  94. package/dist/mongoSchemaReporter.js +82 -0
  95. package/dist/mongoSchemaReporter.js.map +1 -0
  96. package/dist/mongoose.d.ts +48 -0
  97. package/dist/mongoose.d.ts.map +1 -0
  98. package/dist/mongoose.js +331 -0
  99. package/dist/mongoose.js.map +1 -0
  100. package/dist/nPlusOne.d.ts +52 -0
  101. package/dist/nPlusOne.d.ts.map +1 -0
  102. package/dist/nPlusOne.js +0 -0
  103. package/dist/nPlusOne.js.map +1 -0
  104. package/dist/nest.d.ts +31 -0
  105. package/dist/nest.d.ts.map +1 -0
  106. package/dist/nest.js +133 -0
  107. package/dist/nest.js.map +1 -0
  108. package/dist/nextjs.d.ts +27 -0
  109. package/dist/nextjs.d.ts.map +1 -0
  110. package/dist/nextjs.js +178 -0
  111. package/dist/nextjs.js.map +1 -0
  112. package/dist/prisma.d.ts +21 -5
  113. package/dist/prisma.d.ts.map +1 -1
  114. package/dist/prisma.js +247 -22
  115. package/dist/prisma.js.map +1 -1
  116. package/dist/proto.gen.d.ts +19 -1
  117. package/dist/proto.gen.d.ts.map +1 -1
  118. package/dist/registry.d.ts +1 -0
  119. package/dist/registry.d.ts.map +1 -1
  120. package/dist/registry.js +24 -0
  121. package/dist/registry.js.map +1 -1
  122. package/dist/retryStorm.d.ts +42 -0
  123. package/dist/retryStorm.d.ts.map +1 -0
  124. package/dist/retryStorm.js +65 -0
  125. package/dist/retryStorm.js.map +1 -0
  126. package/dist/rulesPoller.d.ts +26 -0
  127. package/dist/rulesPoller.d.ts.map +1 -0
  128. package/dist/rulesPoller.js +74 -0
  129. package/dist/rulesPoller.js.map +1 -0
  130. package/dist/sdk.d.ts +53 -6
  131. package/dist/sdk.d.ts.map +1 -1
  132. package/dist/sdk.js +663 -34
  133. package/dist/sdk.js.map +1 -1
  134. package/dist/sequelize.d.ts.map +1 -1
  135. package/dist/sequelize.js +4 -3
  136. package/dist/sequelize.js.map +1 -1
  137. package/dist/stack.d.ts.map +1 -1
  138. package/dist/stack.js +6 -3
  139. package/dist/stack.js.map +1 -1
  140. package/dist/transport.d.ts.map +1 -1
  141. package/dist/transport.js +2 -1
  142. package/dist/transport.js.map +1 -1
  143. package/dist/typeorm.d.ts +1 -0
  144. package/dist/typeorm.d.ts.map +1 -1
  145. package/dist/typeorm.js +133 -1
  146. package/dist/typeorm.js.map +1 -1
  147. package/package.json +20 -5
@@ -0,0 +1,1231 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Reprova = void 0;
7
+ exports.forwardAsyncErrors = forwardAsyncErrors;
8
+ exports.replayErrorHandler = replayErrorHandler;
9
+ exports.wrapResponse = wrapResponse;
10
+ const node_http_1 = __importDefault(require("node:http"));
11
+ const node_https_1 = __importDefault(require("node:https"));
12
+ const node_fs_1 = require("node:fs");
13
+ const httpFetch_js_1 = require("./httpFetch.js");
14
+ const context_js_1 = require("./context.js");
15
+ const registry_js_1 = require("./registry.js");
16
+ const transport_js_1 = require("./transport.js");
17
+ const stack_js_1 = require("./stack.js");
18
+ const prisma_js_1 = require("./prisma.js");
19
+ const legacyPrisma_js_1 = require("./legacyPrisma.js");
20
+ const nest_js_1 = require("./nest.js");
21
+ const nPlusOne_js_1 = require("./nPlusOne.js");
22
+ const retryStorm_js_1 = require("./retryStorm.js");
23
+ const invariantRules_js_1 = require("./invariantRules.js");
24
+ const rulesPoller_js_1 = require("./rulesPoller.js");
25
+ const idempotency_js_1 = require("./idempotency.js");
26
+ const idempotencySettingsPoller_js_1 = require("./idempotencySettingsPoller.js");
27
+ const invariantInference_js_1 = require("./invariantInference.js");
28
+ const distributionDrift_js_1 = require("./distributionDrift.js");
29
+ const distributionBaselinePoller_js_1 = require("./distributionBaselinePoller.js");
30
+ const mongoose_js_1 = require("./mongoose.js");
31
+ const mongoSchemaReporter_js_1 = require("./mongoSchemaReporter.js");
32
+ // Missing/malformed snapshot degrades to "no seeded settings" — replay mode
33
+ // still runs (context establishment, http_5xx/unhandled_exception capture
34
+ // all work independent of this), just without drift/invariant/idempotency
35
+ // detection firing, exactly like today. Never a hard failure.
36
+ function loadReplaySettingsSnapshot() {
37
+ const path = process.env.REPROVA_REPLAY_SETTINGS_PATH;
38
+ if (!path)
39
+ return undefined;
40
+ try {
41
+ return JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ let instance = null;
48
+ // Zero-code-change async error capture: Express 4 discards the promise an
49
+ // async handler returns, so a rejection never reaches error middleware unless
50
+ // the app hand-plumbs next(err). We patch the router's Layer dispatch (the
51
+ // same semantics Express 5's router ships natively) so every handler's
52
+ // returned promise gets a rejection-forwarder attached automatically.
53
+ const kAsyncWrapped = Symbol('reprova.asyncWrapped');
54
+ // Apps we've already probed (added even when probing no-ops, e.g. Express 5).
55
+ const patchedApps = new WeakSet();
56
+ // Layer prototypes already patched — one per physical express copy.
57
+ const patchedLayerProtos = new WeakSet();
58
+ // Best-effort auto-detection, no app code changes required: req.auth is the
59
+ // express-oauth2-jwt-bearer / Auth0 convention; req.user is what Passport
60
+ // and the overwhelming majority of hand-rolled Express JWT middleware
61
+ // assigns instead (`req.user = decoded` right after verifying the token).
62
+ // req.auth wins when both are present since it's the more specific,
63
+ // auth-only convention — req.user sometimes carries a broader profile.
64
+ // Apps using neither (a custom property, a non-Express framework) still
65
+ // need the explicit setAuthClaims() escape hatch from context.ts.
66
+ function detectAuthClaims(req, customExtractor) {
67
+ if (customExtractor) {
68
+ try {
69
+ const claims = customExtractor(req);
70
+ if (claims !== undefined)
71
+ return claims;
72
+ }
73
+ catch {
74
+ // A misbehaving adapter must never break request capture — fall
75
+ // through to the built-in convention below.
76
+ }
77
+ }
78
+ return req.auth ?? req.user;
79
+ }
80
+ // refreshRequestSnapshot re-reads body and auth claims from the live req at
81
+ // capture time. The request middleware runs at the very front of the stack
82
+ // (before body parsers and auth middleware), so its eager snapshot can miss
83
+ // req.body/req.auth/req.user — by the time anything is captured, all three
84
+ // are final. Mutating ctx.request in place keeps cohort snapshots consistent
85
+ // too.
86
+ function refreshRequestSnapshot(ctx, customExtractor) {
87
+ const req = ctx.rawRequest;
88
+ if (!req)
89
+ return;
90
+ if (req.body !== undefined) {
91
+ ctx.request.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? '');
92
+ }
93
+ const claims = detectAuthClaims(req, customExtractor);
94
+ if (claims !== undefined && ctx.request.authClaims === undefined) {
95
+ ctx.request.authClaims = claims;
96
+ }
97
+ }
98
+ // Fallback for errorHandler(): the request's context, keyed by the raw
99
+ // req/IncomingMessage object rather than AsyncLocalStorage. Needed because
100
+ // ALS's active store can be lost partway through a middleware chain if a
101
+ // middleware BEFORE the route handler resumes its continuation through a
102
+ // promise/microtask chain not properly linked back to the async context ALS
103
+ // was tracking — confirmed live against `celebrate` (a common Express/Joi
104
+ // validation middleware): a route wrapped in celebrate(...) reaches its
105
+ // actual handler with contextStorage.getStore() already undefined, even
106
+ // though the SAME app's non-celebrate routes keep it intact throughout. The
107
+ // req object itself has no such problem — it's the same single reference
108
+ // for the whole request regardless of which continuation mechanism resumed
109
+ // execution, so a direct lookup on it survives whatever broke ALS.
110
+ const ctxByRequest = new WeakMap();
111
+ // Universal HTTP instrumentation: wrap the 'request' event on node's own
112
+ // http/https servers, so EVERY Node app — vanilla http.createServer, Koa,
113
+ // Fastify, Express — runs its request handling inside a Reprova context.
114
+ // Framework middleware becomes an enrichment, not a requirement. Installed
115
+ // once; per-request behavior is gated on the CURRENT init'd instance, so
116
+ // re-inits (and disabled mode) behave correctly without re-patching.
117
+ let httpInstrumented = false;
118
+ function instrumentHttpServers() {
119
+ if (httpInstrumented)
120
+ return;
121
+ httpInstrumented = true;
122
+ // https.Server does NOT inherit from http.Server (it extends tls.Server),
123
+ // and neither defines its own emit — both use EventEmitter's. Patch both
124
+ // prototypes with an own emit that delegates upward.
125
+ for (const proto of [node_http_1.default.Server.prototype, node_https_1.default.Server.prototype]) {
126
+ const originalEmit = proto.emit;
127
+ proto.emit = function (event, ...args) {
128
+ const sdk = instance;
129
+ if (event !== 'request' || !sdk || (sdk.disabled && !sdk.replayMode)) {
130
+ return originalEmit.apply(this, [event, ...args]);
131
+ }
132
+ const req = args[0];
133
+ const res = args[1];
134
+ const ctx = contextFromIncoming(req);
135
+ (0, registry_js_1.registerContext)(ctx);
136
+ ctxByRequest.set(req, ctx);
137
+ res.on('finish', () => {
138
+ // Silent 5xx: the handler responded 5xx without throwing. Vanilla
139
+ // responses have no res.json to wrap, so the check runs at finish.
140
+ if (res.statusCode >= 500 && !ctx.captured) {
141
+ context_js_1.contextStorage.run(ctx, () => sdk.captureHttp5xx(null, res.statusCode, ''));
142
+ }
143
+ (0, registry_js_1.completeContext)(ctx);
144
+ });
145
+ res.on('close', () => (0, registry_js_1.completeContext)(ctx));
146
+ try {
147
+ return context_js_1.contextStorage.run(ctx, () => originalEmit.apply(this, [event, ...args]));
148
+ }
149
+ catch (err) {
150
+ // A synchronous throw unwinds out of the ALS frame before the
151
+ // process-level handler could see the store — capture WITH context
152
+ // here, then rethrow to preserve Node's semantics. The process
153
+ // handler skips already-captured contexts.
154
+ if (err instanceof Error)
155
+ sdk.capture('unhandled_exception', err, ctx);
156
+ throw err;
157
+ }
158
+ };
159
+ }
160
+ }
161
+ function contextFromIncoming(req) {
162
+ let path = req.url ?? '/';
163
+ try {
164
+ path = new URL(path, 'http://placeholder').pathname;
165
+ }
166
+ catch {
167
+ // keep raw url
168
+ }
169
+ const traceId = req.headers['traceparent'] ?? (0, stack_js_1.generateTraceId)();
170
+ // Write the resolved value back onto the raw request's own headers (even
171
+ // when it was just generated, not client-sent) so any framework that
172
+ // builds ITS OWN request representation from these headers — notably
173
+ // Next.js App Router, which constructs a fresh Fetch API Request rather
174
+ // than reusing this IncomingMessage — carries the same traceId forward.
175
+ // That's what lets findContextByTraceId recover THIS exact context later,
176
+ // even across a request-object boundary ALS itself doesn't survive.
177
+ req.headers['traceparent'] = traceId;
178
+ return {
179
+ traceId,
180
+ capturedAt: new Date(),
181
+ request: {
182
+ method: req.method ?? 'GET',
183
+ path,
184
+ headers: (0, stack_js_1.captureHeaders)(req.headers),
185
+ body: '', // refreshed at capture time from rawRequest if a framework parsed it
186
+ },
187
+ footprint: [],
188
+ outboundCalls: [],
189
+ businessInvariantHits: [],
190
+ writeCountDriftHits: [],
191
+ rawRequest: req,
192
+ };
193
+ }
194
+ // The error-middleware layer setupExpress mounted for each app, so the
195
+ // request middleware can keep it at the end of the stack even when routes
196
+ // are registered after setup (position independence).
197
+ const setupErrorLayers = new WeakMap();
198
+ // keepErrorHandlerLast repositions setupExpress's error layer when ROUTE
199
+ // registrations have landed after it — but never past the app's own error
200
+ // middleware (4-arg layers), which must stay downstream so it still receives
201
+ // the error after we capture and next(err) it. Runs from the request
202
+ // middleware, i.e. early in dispatch: moving a NOT-YET-VISITED layer within
203
+ // the live stack array is safe — express iterates with a forward-only index
204
+ // and re-reads stack.length each step, so unvisited layers shift one slot
205
+ // and are still each visited exactly once.
206
+ function keepErrorHandlerLast(app) {
207
+ const errLayer = setupErrorLayers.get(app);
208
+ if (!errLayer)
209
+ return;
210
+ const stack = app._router?.stack;
211
+ if (!stack || stack.length === 0)
212
+ return;
213
+ const i = stack.indexOf(errLayer);
214
+ if (i === -1)
215
+ return;
216
+ // Find the last NON-error layer (handler arity < 4) after ours — a route
217
+ // or middleware registered after setupExpress. If none, we're already
218
+ // positioned correctly (anything after us is user error middleware).
219
+ let lastNonError = -1;
220
+ for (let j = i + 1; j < stack.length; j++) {
221
+ const handle = stack[j].handle;
222
+ if (typeof handle === 'function' && handle.length < 4)
223
+ lastNonError = j;
224
+ }
225
+ if (lastNonError === -1)
226
+ return;
227
+ stack.splice(i, 1);
228
+ // Indices shifted down by one past i; insert right AFTER the late layer.
229
+ stack.splice(lastNonError, 0, errLayer);
230
+ }
231
+ // forwardAsyncErrors applies the rejection-forwarding patch to an app
232
+ // explicitly. requestHandler() does this automatically on the first request,
233
+ // so most apps never call it — it exists for the cases that middleware can't
234
+ // reach: a sub-app running a different physical copy of express, or an app
235
+ // that mounts routes needing Express-5 rejection semantics WITHOUT running
236
+ // the SDK (e.g. the demo app under `repro run` replay, where no DSN is set).
237
+ // Call it after routes are registered. Idempotent.
238
+ function forwardAsyncErrors(app) {
239
+ patchLayerPrototype(app);
240
+ }
241
+ // Express error middleware that emits the structured error `repro run` needs to
242
+ // compare a replayed failure against the originally captured one — the type,
243
+ // message, and normalized stack (via the SAME parseStack used at capture, so
244
+ // the two can never drift). Active ONLY under REPROVA_REPLAY=1, which the CLI
245
+ // sets when it launches the app for replay; otherwise it forwards to the app's
246
+ // own error handler (next(err)), so it's a transparent no-op in normal
247
+ // operation. Register it BEFORE the app's own 500 responder.
248
+ function replayErrorHandler() {
249
+ const replay = process.env.REPROVA_REPLAY === '1';
250
+ return (err, _req, res, next) => {
251
+ if (!replay)
252
+ return next(err);
253
+ res.status(500).json({
254
+ error: {
255
+ type: err.constructor?.name ?? err.name ?? 'Error',
256
+ message: err.message,
257
+ stack: (0, stack_js_1.parseStack)(err),
258
+ },
259
+ });
260
+ };
261
+ }
262
+ function patchLayerPrototype(app) {
263
+ if (patchedApps.has(app))
264
+ return;
265
+ patchedApps.add(app);
266
+ // Express 4 keeps the router at app._router; Express 5 renamed it (and its
267
+ // router forwards rejected promises natively), so failing any of these
268
+ // guards means there is nothing for us to do.
269
+ const router = app._router;
270
+ const firstLayer = router?.stack?.[0];
271
+ if (!firstLayer)
272
+ return;
273
+ const proto = Object.getPrototypeOf(firstLayer);
274
+ if (!proto || typeof proto.handle_request !== 'function')
275
+ return;
276
+ if (patchedLayerProtos.has(proto))
277
+ return;
278
+ patchedLayerProtos.add(proto);
279
+ const original = proto.handle_request;
280
+ proto.handle_request = function handle_request(req, res, next) {
281
+ const fn = this.handle;
282
+ // Wrap plain request handlers only (arity < 4 — express uses fn.length to
283
+ // recognize error middleware; the wrapper keeps arity 3 for the same
284
+ // reason). Wrapping is once per layer, marked with a symbol.
285
+ if (typeof fn === 'function' && fn.length < 4 && !(kAsyncWrapped in fn)) {
286
+ const inner = fn;
287
+ const wrapped = function (rq, rs, nx) {
288
+ const ret = inner(rq, rs, nx);
289
+ if (ret && typeof ret.then === 'function') {
290
+ // Same coercion as Express 5's router: a falsy rejection reason
291
+ // still has to travel the error chain as *something*.
292
+ ret.then(undefined, (err) => nx(err || new Error('unhandled promise rejection in request handler')));
293
+ }
294
+ return ret;
295
+ };
296
+ Object.defineProperty(wrapped, kAsyncWrapped, { value: true });
297
+ this.handle = wrapped;
298
+ }
299
+ return original.call(this, req, res, next);
300
+ };
301
+ }
302
+ class Reprova {
303
+ opts;
304
+ transport;
305
+ rulesPoller;
306
+ idempotencySettingsPoller;
307
+ inferenceReporter;
308
+ distributionDriftReporter;
309
+ distributionBaselinePoller;
310
+ writeCountDriftReporter;
311
+ writeCountBaselinePoller;
312
+ // Unlike the other reporters above, there's nothing to point this at
313
+ // until an app actually calls instrumentMongoose(mongooseInstance) — apps
314
+ // that never do (every SQL-dialect app) pay/leak nothing.
315
+ mongoSchemaReporter;
316
+ instrumentedMongooseInstances = new WeakSet();
317
+ idempotencyKeyCache = new idempotency_js_1.HashMismatchCache(idempotency_js_1.DEFAULT_IDEMPOTENCY_SETTINGS.max_keys, idempotency_js_1.DEFAULT_IDEMPOTENCY_SETTINGS.ttl_ms);
318
+ duplicateReadCache = new idempotency_js_1.HashMismatchCache(idempotency_js_1.DEFAULT_IDEMPOTENCY_SETTINGS.max_keys, idempotency_js_1.DEFAULT_IDEMPOTENCY_SETTINGS.duplicate_read_window_ms);
319
+ release;
320
+ sdkVersion = '0.6.0';
321
+ disabled;
322
+ // True when running under the CLI's `repro run`/`repro test` replay
323
+ // (REPROVA_REPLAY=1). Independent of `disabled`: replay apps have no live
324
+ // DSN (disabled stays true — no network transport, no live polling ever),
325
+ // but still need detection/instrumentation to run against a locally
326
+ // seeded settings snapshot instead of doing nothing. See capture(),
327
+ // static init(), and requestHandler() for where this is checked.
328
+ replayMode;
329
+ migrationId = 'unknown';
330
+ authClaimsExtractor;
331
+ outboundOpts;
332
+ n1Threshold;
333
+ retryStormThreshold;
334
+ constructor(opts) {
335
+ this.opts = opts;
336
+ this.disabled = !opts.dsn;
337
+ this.replayMode = process.env.REPROVA_REPLAY === '1';
338
+ if (this.disabled) {
339
+ // Inert transport/pollers: constructed so internal wiring stays
340
+ // uniform, but never started — capture() never enqueues (outside
341
+ // replay mode), no rule ever evaluates true (getInvariantRules() stays
342
+ // empty), and idempotency checks stay off (getIdempotencySettings()
343
+ // stays at the disabled-safe default) UNLESS replay mode seeds them
344
+ // from a one-time snapshot below (still never .start()'d — no live
345
+ // polling from inside a replayed app either way).
346
+ this.transport = new transport_js_1.BatchTransport({ endpoint: 'http://disabled.invalid/v1/ingest', apiKey: '' });
347
+ this.rulesPoller = new rulesPoller_js_1.RulesPoller({ endpoint: 'http://disabled.invalid/v1/business-invariant-rules', apiKey: '' });
348
+ this.idempotencySettingsPoller = new idempotencySettingsPoller_js_1.IdempotencySettingsPoller({ endpoint: 'http://disabled.invalid/v1/settings/idempotency', apiKey: '' });
349
+ this.inferenceReporter = new invariantInference_js_1.InferenceReporter({ endpoint: 'http://disabled.invalid/v1/inferred-invariants/observe', apiKey: '' });
350
+ this.distributionDriftReporter = new distributionDrift_js_1.DistributionDriftReporter({ endpoint: 'http://disabled.invalid/v1/distribution-baselines/observe', apiKey: '' });
351
+ this.distributionBaselinePoller = new distributionBaselinePoller_js_1.DistributionBaselinePoller({ endpoint: 'http://disabled.invalid/v1/distribution-baselines', apiKey: '' });
352
+ this.writeCountDriftReporter = new distributionDrift_js_1.DistributionDriftReporter({ endpoint: 'http://disabled.invalid/v1/write-count-baselines/observe', apiKey: '' });
353
+ this.writeCountBaselinePoller = new distributionBaselinePoller_js_1.DistributionBaselinePoller({ endpoint: 'http://disabled.invalid/v1/write-count-baselines', apiKey: '' });
354
+ if (this.replayMode) {
355
+ const snapshot = loadReplaySettingsSnapshot();
356
+ if (snapshot?.rules)
357
+ this.rulesPoller.seed(snapshot.rules);
358
+ if (snapshot?.distributionBaselines)
359
+ this.distributionBaselinePoller.seed(snapshot.distributionBaselines);
360
+ if (snapshot?.writeCountBaselines)
361
+ this.writeCountBaselinePoller.seed(snapshot.writeCountBaselines);
362
+ if (snapshot?.idempotencySettings)
363
+ this.idempotencySettingsPoller.seed(snapshot.idempotencySettings);
364
+ }
365
+ }
366
+ else {
367
+ const url = new URL(opts.dsn);
368
+ const apiKey = url.pathname.slice(1); // strip leading slash
369
+ const endpoint = `${url.protocol}//${url.host}/v1/ingest`;
370
+ this.transport = new transport_js_1.BatchTransport({ endpoint, apiKey });
371
+ this.transport.start();
372
+ this.rulesPoller = new rulesPoller_js_1.RulesPoller({
373
+ endpoint: `${url.protocol}//${url.host}/v1/business-invariant-rules`,
374
+ apiKey,
375
+ intervalMs: opts.rulesRefreshIntervalMs,
376
+ });
377
+ this.rulesPoller.start();
378
+ this.idempotencySettingsPoller = new idempotencySettingsPoller_js_1.IdempotencySettingsPoller({
379
+ endpoint: `${url.protocol}//${url.host}/v1/settings/idempotency`,
380
+ apiKey,
381
+ intervalMs: opts.rulesRefreshIntervalMs,
382
+ });
383
+ this.idempotencySettingsPoller.start();
384
+ this.inferenceReporter = new invariantInference_js_1.InferenceReporter({
385
+ endpoint: `${url.protocol}//${url.host}/v1/inferred-invariants/observe`,
386
+ apiKey,
387
+ });
388
+ this.inferenceReporter.start();
389
+ this.distributionDriftReporter = new distributionDrift_js_1.DistributionDriftReporter({
390
+ endpoint: `${url.protocol}//${url.host}/v1/distribution-baselines/observe`,
391
+ apiKey,
392
+ });
393
+ this.distributionDriftReporter.start();
394
+ this.distributionBaselinePoller = new distributionBaselinePoller_js_1.DistributionBaselinePoller({
395
+ endpoint: `${url.protocol}//${url.host}/v1/distribution-baselines`,
396
+ apiKey,
397
+ intervalMs: opts.rulesRefreshIntervalMs,
398
+ });
399
+ this.distributionBaselinePoller.start();
400
+ this.writeCountDriftReporter = new distributionDrift_js_1.DistributionDriftReporter({
401
+ endpoint: `${url.protocol}//${url.host}/v1/write-count-baselines/observe`,
402
+ apiKey,
403
+ });
404
+ this.writeCountDriftReporter.start();
405
+ this.writeCountBaselinePoller = new distributionBaselinePoller_js_1.DistributionBaselinePoller({
406
+ endpoint: `${url.protocol}//${url.host}/v1/write-count-baselines`,
407
+ apiKey,
408
+ intervalMs: opts.rulesRefreshIntervalMs,
409
+ });
410
+ this.writeCountBaselinePoller.start();
411
+ }
412
+ this.release = opts.release;
413
+ this.outboundOpts = opts.recordOutbound;
414
+ this.n1Threshold = opts.n1Threshold ?? 5;
415
+ this.retryStormThreshold = opts.retryStormThreshold ?? 3;
416
+ // Opts into `await using sdk = Reprova.init(...)` (TC39 explicit
417
+ // resource management) on runtimes that have it, WITHOUT declaring a
418
+ // [Symbol.asyncDispose] class member — that syntax requires the
419
+ // computed key to be a `unique symbol` type, which in turn requires
420
+ // every CONSUMER's own tsconfig to include the esnext.disposable lib
421
+ // just to type-check a feature they may never use (most projects'
422
+ // `lib` arrays don't include it). Assigning it dynamically here is a
423
+ // plain runtime property write, invisible to the type checker
424
+ // entirely, so it can't break anyone's build regardless of their
425
+ // `lib` setting — identical behavior for anyone actually using `await
426
+ // using`, zero effect on everyone else.
427
+ const asyncDisposeSymbol = Symbol.asyncDispose;
428
+ if (asyncDisposeSymbol) {
429
+ this[asyncDisposeSymbol] = () => this.disposeInternal();
430
+ }
431
+ }
432
+ // Live-cached rules for the tenant, refreshed on rulesRefreshIntervalMs —
433
+ // read by the Prisma extension on every query, never re-fetched per-query.
434
+ getInvariantRules() {
435
+ return this.rulesPoller.getEnabledRules();
436
+ }
437
+ static init(opts) {
438
+ instance = new Reprova(opts);
439
+ // replayMode runs this block too even though disabled stays true — a
440
+ // replayed app still needs context establishment (so detectors have
441
+ // something to attach to) and instrumentation, just with no live
442
+ // transport/polling underneath it.
443
+ if (!instance.disabled || instance.replayMode) {
444
+ instance.patchOutbound();
445
+ if (opts.instrumentHttp !== false)
446
+ instrumentHttpServers();
447
+ if (opts.processHandlers !== false)
448
+ instance.attachProcessHandlers();
449
+ }
450
+ return instance;
451
+ }
452
+ static getInstance() { return instance; }
453
+ setMigrationId(id) { this.migrationId = id; }
454
+ // Generic per-framework extension point — see frameworkAdapter.ts for why
455
+ // this is two seams (auth detection, migration_id) rather than a bigger
456
+ // plugin system. resolveMigrationId is awaited once, at registration time;
457
+ // call registerFrameworkAdapter again (or setMigrationId directly) if it
458
+ // can change during the process's lifetime.
459
+ registerFrameworkAdapter(adapter) {
460
+ if (adapter.extractAuthClaims)
461
+ this.authClaimsExtractor = adapter.extractAuthClaims;
462
+ if (adapter.resolveMigrationId) {
463
+ adapter
464
+ .resolveMigrationId()
465
+ .then((id) => this.setMigrationId(id))
466
+ .catch(() => {
467
+ // Never let a broken migration-id resolver affect anything else —
468
+ // migrationId simply stays whatever it already was ('unknown' by
469
+ // default).
470
+ });
471
+ }
472
+ }
473
+ // One-call Prisma integration. Splices the footprint-recording extension
474
+ // onto an existing PrismaClient (in place, preserving its identity) and
475
+ // best-effort reads the latest applied migration into migration_id.
476
+ //
477
+ // Typed structurally so the SDK never has to import '@prisma/client' —
478
+ // Prisma stays an optional peer. No-op when the SDK is disabled (no DSN /
479
+ // replay), like recordFootprint, so callers need no `if (dsn)` guard.
480
+ //
481
+ // Mutates `client` in place via Object.assign($extends(...)) because
482
+ // $extends returns a NEW client and the shared singleton must keep
483
+ // recording. Await it so migration_id is set before the first request.
484
+ async instrumentPrisma(client, opts) {
485
+ // Found live while proving the replay-mode detector fix end to end: this
486
+ // gate originally matched every other disabled-mode no-op, but replay
487
+ // mode needs the Prisma extension actually installed — that's the ONLY
488
+ // thing that runs applyInvariantRules()/observeWriteCountDrift() at all.
489
+ // Nothing else in this method touches opts.dsn or the network, so
490
+ // widening the gate is safe.
491
+ if (this.disabled && !this.replayMode)
492
+ return;
493
+ const c = client;
494
+ // 1. Footprint capture. Two paths, same recording logic underneath
495
+ // (recordPrismaOperation, shared by both): $extends (Prisma's
496
+ // Client Extensions API, 4.16+) is the modern, official hook — extend,
497
+ // then copy the extended client's behavior back onto the original so
498
+ // every importer of the singleton records. Older clients have no
499
+ // $extends (and no $use, its deprecated predecessor) at all —
500
+ // confirmed live against a real @prisma/client@2.0.0-beta.4 — so for
501
+ // those, legacyPrisma.ts monkey-patches each model delegate's own CRUD
502
+ // methods directly instead, the same shape this SDK already uses for
503
+ // TypeORM's QueryRunner and Mongoose's pre-compiled models.
504
+ if (typeof c.$extends === 'function') {
505
+ const ext = (0, prisma_js_1.createPrismaExtension)(opts?.dmmf, () => this.getInvariantRules(),
506
+ // Suspended: invariant inference ("Suggested invariants") is paused —
507
+ // pass undefined instead of this.inferenceReporter so
508
+ // observeInferredInvariants never runs. Not removed: the reporter,
509
+ // the observation logic, and the control-plane endpoints are all
510
+ // still intact; pass this.inferenceReporter here again to re-enable.
511
+ undefined, this.writeCountDriftReporter, this.writeCountBaselinePoller);
512
+ Object.assign(c, c.$extends(ext));
513
+ }
514
+ else {
515
+ (0, legacyPrisma_js_1.instrumentLegacyPrismaClient)(client, {
516
+ dmmf: opts?.dmmf,
517
+ getRules: () => this.getInvariantRules(),
518
+ inferenceReporter: undefined, // suspended, same as the modern path above
519
+ writeCountDriftReporter: this.writeCountDriftReporter,
520
+ writeCountBaselinePoller: this.writeCountBaselinePoller,
521
+ });
522
+ }
523
+ // 2. migration_id: best-effort. Absent table (migrations not run) or any
524
+ // query error leaves it at the 'unknown' default — never fatal at boot.
525
+ // Three tries, in order: modern `prisma migrate`'s `_prisma_migrations`
526
+ // (Postgres/MySQL dialect, then SQL Server's TOP-instead-of-LIMIT
527
+ // dialect — the SDK deliberately has no notion of "which dialect" since
528
+ // it never imports @prisma/client), then the OLD experimental Prisma
529
+ // Migrate's `_Migration` table (singular, capitalized — confirmed live
530
+ // against a real @prisma/client@2.0.0-beta.4 project: that era tracked
531
+ // migrations in `_Migration(name, status, finished_at)`, not
532
+ // `_prisma_migrations`, and only reached "success" rows count). Also
533
+ // tries `client.raw` (that same ancient client's pre-$queryRaw tagged-
534
+ // template method — confirmed live to behave identically for a SELECT)
535
+ // when `$queryRaw` isn't there at all.
536
+ if (opts?.readMigrationId !== false) {
537
+ const rawQuery = c.$queryRaw ?? c.raw;
538
+ if (rawQuery) {
539
+ let rows = [];
540
+ try {
541
+ rows = await rawQuery.bind(c) `
542
+ SELECT migration_name FROM _prisma_migrations ORDER BY finished_at DESC LIMIT 1
543
+ `;
544
+ }
545
+ catch {
546
+ try {
547
+ rows = await rawQuery.bind(c) `
548
+ SELECT TOP 1 migration_name FROM _prisma_migrations ORDER BY finished_at DESC
549
+ `;
550
+ }
551
+ catch {
552
+ try {
553
+ const legacyRows = await rawQuery.bind(c) `
554
+ SELECT name FROM _Migration WHERE status = 'MigrationSuccess' ORDER BY finished_at DESC LIMIT 1
555
+ `;
556
+ rows = legacyRows.map((r) => ({ migration_name: r.name }));
557
+ }
558
+ catch {
559
+ // migration_id stays 'unknown'
560
+ }
561
+ }
562
+ }
563
+ if (rows[0]?.migration_name) {
564
+ this.setMigrationId(rows[0].migration_name);
565
+ }
566
+ }
567
+ }
568
+ }
569
+ // One-call Mongoose integration: installs the footprint plugin (unless
570
+ // this exact mongoose instance already has it — repeat calls are harmless)
571
+ // and starts a MongoSchemaReporter pointed at it, so reference edges
572
+ // (ref: declarations across every compiled model) get reported to the
573
+ // control plane without the app doing any manual config. No-op when the
574
+ // SDK is disabled, like instrumentPrisma/recordFootprint.
575
+ instrumentMongoose(mongooseInstance) {
576
+ // Footprint capture (this half) needs to run in replay mode too — it's
577
+ // what feeds the adapter-generic detectors (n_plus_one_query,
578
+ // retry_storm) that don't depend on a live DSN at all. The schema
579
+ // reporter below genuinely needs one (it POSTs discovered reference
580
+ // edges to a live control plane), so THAT half stays disabled-only,
581
+ // replay mode included — there's no dsn to build its endpoint from.
582
+ if (this.disabled && !this.replayMode)
583
+ return;
584
+ if (!this.instrumentedMongooseInstances.has(mongooseInstance)) {
585
+ this.instrumentedMongooseInstances.add(mongooseInstance);
586
+ mongooseInstance.plugin(mongoose_js_1.reprovaMongoosePlugin);
587
+ }
588
+ // Covers models compiled BEFORE this call — mongoose.plugin() above
589
+ // only reaches schemas compiled after it, so an app whose own bootstrap
590
+ // already compiled a model earlier (or that simply calls
591
+ // instrumentMongoose() late) would otherwise capture nothing for it.
592
+ (0, mongoose_js_1.instrumentAlreadyCompiledModels)(mongooseInstance);
593
+ if (this.disabled)
594
+ return; // replay mode, no live dsn — nothing to report edges to
595
+ this.mongoSchemaReporter?.stop();
596
+ const url = new URL(this.opts.dsn);
597
+ const apiKey = url.pathname.slice(1);
598
+ this.mongoSchemaReporter = new mongoSchemaReporter_js_1.MongoSchemaReporter({
599
+ endpoint: `${url.protocol}//${url.host}/v1/mongo-schema/observe`,
600
+ apiKey,
601
+ mongooseInstance,
602
+ });
603
+ this.mongoSchemaReporter.start();
604
+ }
605
+ // Escape hatch for apps using the raw MongoDB driver (no Mongoose, hence
606
+ // no schema registry to auto-discover ref: edges from). Mirrors
607
+ // registerFrameworkAdapter's philosophy — a seam, not a plugin system:
608
+ // supply the same {from_collection, from_field, to_collection, array}
609
+ // shape by hand instead of it being walked from schema.paths. Feeds the
610
+ // same reporting path a Mongoose app's auto-discovery would, just without
611
+ // the "model" indirection (raw-driver apps only ever deal in collection
612
+ // names, so edges here are already fully resolved — no to_model lookup
613
+ // needed control-plane-side).
614
+ registerReferenceEdges(edges) {
615
+ if (this.disabled)
616
+ return;
617
+ const url = new URL(this.opts.dsn);
618
+ const apiKey = url.pathname.slice(1);
619
+ const endpoint = `${url.protocol}//${url.host}/v1/mongo-schema/observe`;
620
+ // A raw-driver edge already names its target by collection, not by a
621
+ // Mongoose model — reusing each target collection as its own "model"
622
+ // name still resolves correctly, because RecordMongoSchema resolves
623
+ // ToModel against this SAME report's Models list, and a self-
624
+ // referential entry (model name == collection name) makes every edge
625
+ // resolve to itself with zero extra server-side logic.
626
+ const collections = new Set(edges.map((e) => e.toCollection));
627
+ const models = [...collections].map((collection) => ({ model: collection, collection }));
628
+ const wireEdges = edges.map((e) => ({ from_collection: e.fromCollection, from_field: e.fromField, to_model: e.toCollection, array: e.array }));
629
+ void (0, httpFetch_js_1.httpFetch)(endpoint, {
630
+ method: 'POST',
631
+ headers: { 'Content-Type': 'application/json', 'X-Reprova-Key': apiKey },
632
+ body: JSON.stringify({ models, edges: wireEdges }),
633
+ }).catch(() => {
634
+ // Best-effort, same as every other reporter — never break the host app.
635
+ });
636
+ }
637
+ // One-call Express integration (Sentry-style): mounts the request context
638
+ // middleware (repositioned to run before everything, wherever setup is
639
+ // called), the error-capture middleware (kept last even if routes are
640
+ // registered later), the http_5xx response hook, and async rejection
641
+ // forwarding. Equivalent to the manual requestHandler/errorHandler/
642
+ // wrapResponse wiring — same capture payloads, one line.
643
+ // ExpressAppLike (see its own doc comment above) rather than express's
644
+ // own Application/Express type — either real type satisfies this
645
+ // trivially, regardless of which @types/express version produced it.
646
+ setupExpress(app) {
647
+ app.use(this.requestHandler());
648
+ const router = app._router;
649
+ const stack = router?.stack;
650
+ if (stack && stack.length > 0) {
651
+ // Move the just-pushed request layer to sit right after express's
652
+ // leading built-ins (query/expressInit), so it runs first even when
653
+ // setupExpress is called after the routes.
654
+ const reqLayer = stack[stack.length - 1];
655
+ let insertAt = 0;
656
+ while (insertAt < stack.length - 1) {
657
+ const name = stack[insertAt].name;
658
+ if (name === 'query' || name === 'expressInit') {
659
+ insertAt++;
660
+ }
661
+ else {
662
+ break;
663
+ }
664
+ }
665
+ stack.splice(stack.length - 1, 1);
666
+ stack.splice(insertAt, 0, reqLayer);
667
+ }
668
+ app.use(this.errorHandler());
669
+ if (stack && stack.length > 0) {
670
+ setupErrorLayers.set(app, stack[stack.length - 1]);
671
+ }
672
+ }
673
+ // Express middleware: opens per-request context. Also installs the async
674
+ // rejection forwarder on first request (see patchLayerPrototype) so plain
675
+ // `async (req, res) => ...` handlers need no wrapper and no next(err).
676
+ requestHandler() {
677
+ return (req, res, next) => {
678
+ if (req.app) {
679
+ patchLayerPrototype(req.app);
680
+ keepErrorHandlerLast(req.app);
681
+ }
682
+ // http_5xx-without-throw hook rides along automatically.
683
+ if (!this.disabled || this.replayMode)
684
+ wrapResponse(res, this);
685
+ // With HTTP-server instrumentation active (init default), a context
686
+ // for this very request already exists — don't shadow it with a
687
+ // second one; every capture path reads the same store either way.
688
+ const existing = context_js_1.contextStorage.getStore();
689
+ if (existing && existing.rawRequest === req) {
690
+ next();
691
+ return;
692
+ }
693
+ const traceId = req.headers['traceparent'] ?? (0, stack_js_1.generateTraceId)();
694
+ const ctx = {
695
+ traceId,
696
+ capturedAt: new Date(),
697
+ request: {
698
+ method: req.method,
699
+ path: req.path,
700
+ headers: (0, stack_js_1.captureHeaders)(req.headers),
701
+ body: typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? ''),
702
+ authClaims: detectAuthClaims(req, this.authClaimsExtractor),
703
+ },
704
+ footprint: [],
705
+ outboundCalls: [],
706
+ businessInvariantHits: [],
707
+ writeCountDriftHits: [],
708
+ rawRequest: req,
709
+ };
710
+ // Track this context so a concurrent request's capture can see it
711
+ // (and, briefly after completion, that it recently overlapped).
712
+ (0, registry_js_1.registerContext)(ctx);
713
+ ctxByRequest.set(req, ctx);
714
+ const done = () => (0, registry_js_1.completeContext)(ctx);
715
+ res.on('finish', done);
716
+ res.on('close', done);
717
+ context_js_1.contextStorage.run(ctx, next);
718
+ };
719
+ }
720
+ // Express error middleware: captures unhandled_exception. Falls back to
721
+ // ctxByRequest when the AsyncLocalStorage store has gone missing by the
722
+ // time an error reaches here (see ctxByRequest's own comment) — a route
723
+ // wrapped in some validation middlewares would otherwise capture nothing
724
+ // but a switch to the generic http_5xx fallback: a real error with a real
725
+ // stack, silently downgraded to a synthetic "HTTP 500 response".
726
+ errorHandler() {
727
+ return (err, req, res, next) => {
728
+ const ctx = context_js_1.contextStorage.getStore() ?? ctxByRequest.get(req);
729
+ if (ctx && err instanceof Error) {
730
+ this.capture('unhandled_exception', err, ctx);
731
+ }
732
+ next(err);
733
+ };
734
+ }
735
+ // Manual capture.
736
+ captureError(err, opts) {
737
+ const ctx = context_js_1.contextStorage.getStore();
738
+ this.capture(opts?.trigger ?? 'manual_capture', err, ctx);
739
+ }
740
+ // NestJS global exception filter: hands Nest-caught exceptions to the
741
+ // capture pipeline with the ORIGINAL stack (Nest's exception layer
742
+ // otherwise converts them to 500s before Express error middleware ever
743
+ // sees them, leaving only the silent-5xx capture), honors the
744
+ // REPROVA_REPLAY contract for exact replay verdicts, and responds the way
745
+ // Nest's own default filter would. Usage:
746
+ // app.useGlobalFilters(sdk.nestExceptionFilter());
747
+ nestExceptionFilter() {
748
+ return (0, nest_js_1.createNestExceptionFilter)({
749
+ capture: (err) => {
750
+ const ctx = context_js_1.contextStorage.getStore();
751
+ if (!ctx?.captured)
752
+ this.capture('unhandled_exception', err, ctx);
753
+ },
754
+ });
755
+ }
756
+ // Recovers request context across Nest's own pipe resolution — see
757
+ // createNestContextInterceptor's doc comment in nest.ts for the full story
758
+ // (found and confirmed against a real NestJS 7 app: any route with a
759
+ // @Body() DTO reached its service/repository code with
760
+ // contextStorage.getStore() already empty). resolveContext reuses the SAME
761
+ // req-keyed side channel recordFootprint() already falls back to — the
762
+ // interceptor is just the one place in a Nest app that reliably has the
763
+ // raw request AND runs early enough to re-anchor context before any pipe
764
+ // or controller code executes, which no ORM adapter can do on its own
765
+ // (none of them have the request object in scope).
766
+ nestContextInterceptor() {
767
+ return (0, nest_js_1.createNestContextInterceptor)({
768
+ resolveContext: (req) => (req !== undefined ? ctxByRequest.get(req) : undefined) ?? context_js_1.contextStorage.getStore(),
769
+ runInContext: (ctx, fn) => context_js_1.contextStorage.run(ctx, fn),
770
+ });
771
+ }
772
+ // One-call NestJS setup, the setupExpress of the Nest world: registers the
773
+ // exception filter and context-recovery interceptor, and (when given a
774
+ // Prisma client) wires automatic data footprints + migration_id in the
775
+ // same breath. The whole integration:
776
+ // const sdk = Reprova.init({ dsn, release });
777
+ // const app = await NestFactory.create(AppModule);
778
+ // await sdk.setupNest(app, { prisma: app.get(PrismaService), dmmf: Prisma.dmmf });
779
+ // Other ORMs stay their usual one line (installTypeOrmSubscriber(dataSource),
780
+ // installKnexHooks(knex), ...) — they aren't Reprova-instance methods, but
781
+ // the interceptor registered here benefits ALL of them equally: it fixes
782
+ // context before any ORM code runs, not per-adapter.
783
+ //
784
+ // The interceptor is registered here, first, deliberately — Nest merges
785
+ // multiple useGlobalInterceptors() calls in registration order, and
786
+ // registering ours before any app-defined interceptor makes it the
787
+ // outermost wrapper, covering the whole remaining chain (every other
788
+ // interceptor, every pipe, the controller itself).
789
+ async setupNest(app, opts) {
790
+ app.useGlobalFilters(this.nestExceptionFilter());
791
+ app.useGlobalInterceptors(this.nestContextInterceptor());
792
+ if (opts?.prisma) {
793
+ await this.instrumentPrisma(opts.prisma, { dmmf: opts.dmmf });
794
+ }
795
+ }
796
+ // Called by response hook for http_5xx (no thrown error).
797
+ captureHttp5xx(err, statusCode, responseBody) {
798
+ const ctx = context_js_1.contextStorage.getStore();
799
+ if (!ctx)
800
+ return;
801
+ // Skip: an error was already thrown and captured for this request (e.g. via
802
+ // errorHandler/captureError) — this 5xx response is just that error surfacing,
803
+ // not an independent silent failure. Reporting both would double-count the issue.
804
+ if (ctx.captured)
805
+ return;
806
+ const syntheticErr = err ?? new Error(`HTTP ${statusCode} response`);
807
+ this.capture('http_5xx', syntheticErr, ctx, {
808
+ response: { status: statusCode, body: responseBody.slice(0, 16 * 1024) },
809
+ });
810
+ }
811
+ // Called by response hook on every response, success or failure. Unlike
812
+ // captureHttp5xx this isn't gated on status code — an N+1 request usually
813
+ // returns 200 while doing far more DB round-trips than it should, which is
814
+ // exactly the case with no exception and no 5xx to hook into otherwise.
815
+ // Skips (like captureHttp5xx) once this request already has a capture, so
816
+ // a request that both throws AND has an N+1 pattern doesn't get reported
817
+ // twice — the thrown error takes priority. One capture PER offending
818
+ // (model, op) pair, not one aggregating all of them: that keeps each
819
+ // pattern's fingerprint stable and independently trackable as its own
820
+ // issue, rather than lumping unrelated repeated-query patterns together
821
+ // or producing a message whose composition varies request to request.
822
+ captureNPlusOneIfAny() {
823
+ if (this.n1Threshold <= 0)
824
+ return;
825
+ const ctx = context_js_1.contextStorage.getStore();
826
+ if (!ctx || ctx.captured)
827
+ return;
828
+ for (const finding of (0, nPlusOne_js_1.detectNPlusOne)(ctx.footprint, this.n1Threshold)) {
829
+ this.capture('n_plus_one_query', new nPlusOne_js_1.PotentialNPlusOneQuery(finding), ctx);
830
+ }
831
+ }
832
+ // Same shape and same reasoning as captureNPlusOneIfAny: not gated on
833
+ // status code (the OUTER request can easily still return 200 — an
834
+ // upstream dependency getting hammered by retries doesn't necessarily
835
+ // fail the request that triggered them), skipped once this request
836
+ // already has a capture, one capture per offending (host, method, path)
837
+ // group rather than one aggregating every group found.
838
+ captureRetryStormIfAny() {
839
+ if (this.retryStormThreshold <= 0)
840
+ return;
841
+ const ctx = context_js_1.contextStorage.getStore();
842
+ if (!ctx || ctx.captured)
843
+ return;
844
+ for (const finding of (0, retryStorm_js_1.detectRetryStorm)(ctx.outboundCalls, this.retryStormThreshold)) {
845
+ this.capture('retry_storm', new retryStorm_js_1.PotentialRetryStorm(finding), ctx);
846
+ }
847
+ }
848
+ // Drains business-invariant violations the Prisma extension recorded
849
+ // during this request (prisma.ts — the only place the actual row values a
850
+ // rule needs are available). One capture per violated rule, same
851
+ // one-per-pattern reasoning as captureNPlusOneIfAny; errorType is set
852
+ // explicitly per rule so different rules fingerprint into different
853
+ // issues despite sharing one Error class.
854
+ captureBusinessInvariantViolationsIfAny() {
855
+ const ctx = context_js_1.contextStorage.getStore();
856
+ if (!ctx || ctx.captured)
857
+ return;
858
+ for (const hit of ctx.businessInvariantHits) {
859
+ this.capture('business_invariant', new invariantRules_js_1.BusinessInvariantViolation(hit.message), ctx, {
860
+ errorType: `InvariantViolation:${hit.ruleId}`,
861
+ });
862
+ }
863
+ }
864
+ // Drains write-count-drift hits the Prisma extension recorded during this
865
+ // request (prisma.ts's observeWriteCountDrift — the only place a bulk
866
+ // write's affected-row count is available). Same one-per-hit shape as
867
+ // captureBusinessInvariantViolationsIfAny; errorType is per (route,
868
+ // operation, model) so distinct bulk-write anomalies fingerprint into
869
+ // distinct issues despite sharing one Error class.
870
+ captureWriteCountDriftIfAny() {
871
+ const ctx = context_js_1.contextStorage.getStore();
872
+ if (!ctx || ctx.captured)
873
+ return;
874
+ for (const hit of ctx.writeCountDriftHits) {
875
+ this.capture('write_count_drift', new distributionDrift_js_1.WriteCountDrift(`${hit.route} ${hit.operation} on ${hit.model} affected an unusual number of rows`), ctx, { errorType: `WriteCountDrift:${hit.route}:${hit.operation}:${hit.model}` });
876
+ }
877
+ }
878
+ // Unlike the other capture*IfAny methods, this one needs the actual
879
+ // response body — not something already sitting on ctx — so it's called
880
+ // directly from wrapResponse's res.json override with body in hand,
881
+ // rather than generically after the fact. Checks the idempotency-key
882
+ // detector first; if that already captured (one per request, same as
883
+ // every other trigger), the duplicate-read detector is skipped rather
884
+ // than risk a second capture for the same response.
885
+ checkIdempotencyIfAny(body) {
886
+ const ctx = context_js_1.contextStorage.getStore();
887
+ if (!ctx || ctx.captured)
888
+ return;
889
+ const settings = this.idempotencySettingsPoller.getSettings();
890
+ const req = ctx.rawRequest;
891
+ const routePattern = req?.route?.path;
892
+ const method = ctx.request.method;
893
+ if (settings.key_check_enabled) {
894
+ const key = (0, idempotency_js_1.findIdempotencyKey)(ctx.request.headers, settings.header_names);
895
+ if (key) {
896
+ this.idempotencyKeyCache.configure(settings.max_keys, settings.ttl_ms);
897
+ const hash = (0, idempotency_js_1.hashResponseBody)(body);
898
+ const signature = `${method} ${routePattern ?? ''} ${key}`;
899
+ if (this.idempotencyKeyCache.checkAndRecord(signature, hash)) {
900
+ this.capture('idempotency_key_mismatch', new idempotency_js_1.IdempotencyKeyMismatch(`Idempotent ${method} request${routePattern ? ` to ${routePattern}` : ''} returned a different response for the same idempotency key`), ctx, { errorType: 'IdempotencyKeyMismatch' });
901
+ return;
902
+ }
903
+ }
904
+ }
905
+ if (settings.duplicate_read_enabled && method === 'GET') {
906
+ const identity = (0, idempotency_js_1.extractIdentity)(ctx.request.authClaims);
907
+ if (identity) {
908
+ const rawUrl = req?.originalUrl ?? req?.url ?? '';
909
+ const query = rawUrl.split('?')[1] ?? '';
910
+ this.duplicateReadCache.configure(settings.max_keys, settings.duplicate_read_window_ms);
911
+ const hash = (0, idempotency_js_1.hashResponseBody)(body);
912
+ const signature = `${routePattern ?? ctx.request.path} ${query} identity:${identity}`;
913
+ if (this.duplicateReadCache.checkAndRecord(signature, hash)) {
914
+ this.capture('duplicate_read_mismatch', new idempotency_js_1.DuplicateReadMismatch(`Duplicate GET${routePattern ? ` to ${routePattern}` : ''} within ${settings.duplicate_read_window_ms}ms returned a different result`), ctx, { errorType: 'DuplicateReadMismatch' });
915
+ }
916
+ }
917
+ }
918
+ }
919
+ // Numeric-only v1 slice (see distributionDrift.ts's module doc). Always
920
+ // records every observed value into the local reporter regardless of
921
+ // whether drift detection itself is enabled — the baseline needs to keep
922
+ // accumulating so it's actually populated by the time an admin flips the
923
+ // dashboard toggle on, rather than starting from zero at that moment.
924
+ // Only the CAPTURE side is gated on the polled enabled flag.
925
+ checkDistributionDriftIfAny(body) {
926
+ const ctx = context_js_1.contextStorage.getStore();
927
+ if (!ctx)
928
+ return;
929
+ const req = ctx.rawRequest;
930
+ const routePattern = req?.route?.path;
931
+ if (!routePattern)
932
+ return; // no stable key to group on — a resolved path would leak a real id into the key
933
+ const route = `${ctx.request.method} ${routePattern}`;
934
+ const fields = (0, distributionDrift_js_1.extractTopLevelNumericFields)(body);
935
+ if (Object.keys(fields).length === 0)
936
+ return;
937
+ for (const [field, value] of Object.entries(fields)) {
938
+ this.distributionDriftReporter.record(route, field, value);
939
+ }
940
+ if (!this.distributionBaselinePoller.isEnabled() || ctx.captured)
941
+ return;
942
+ for (const [field, value] of Object.entries(fields)) {
943
+ const baseline = this.distributionBaselinePoller.getBaseline(route, field);
944
+ if (baseline && (0, distributionDrift_js_1.isDrift)(value, baseline)) {
945
+ this.capture('value_distribution_drift', new distributionDrift_js_1.ValueDistributionDrift(`${route} field "${field}" is far outside its observed baseline range`), ctx, { errorType: `ValueDistributionDrift:${route}:${field}` });
946
+ return; // one capture max per response, same as every other *IfAny
947
+ }
948
+ }
949
+ }
950
+ capture(trigger, err, ctx, extra) {
951
+ if (this.disabled) {
952
+ // Replay mode: there's no live control plane to report to (the app is
953
+ // running against an anonymized disposable DB, not production), so
954
+ // instead of a network capture, stash the same {type, message} a real
955
+ // occurrence would have stored — extra?.errorType ?? the class name,
956
+ // exactly matching the payload's own error.type resolution below —
957
+ // onto the context. wrapResponse surfaces it via a response header for
958
+ // the CLI's verdict comparison to read instead of the transport.
959
+ // First-wins, same one-capture-per-response contract every other
960
+ // trigger already follows.
961
+ if (this.replayMode && ctx && !ctx.captured) {
962
+ ctx.captured = true;
963
+ ctx.replayDetection = {
964
+ trigger,
965
+ type: extra?.errorType ?? (err.constructor?.name ?? err.name ?? 'Error'),
966
+ message: err.message,
967
+ };
968
+ }
969
+ return;
970
+ }
971
+ if (ctx) {
972
+ ctx.captured = true;
973
+ refreshRequestSnapshot(ctx, this.authClaimsExtractor);
974
+ }
975
+ const payload = {
976
+ trigger: trigger,
977
+ trace_id: ctx?.traceId ?? (0, stack_js_1.generateTraceId)(),
978
+ timestamp: (ctx?.capturedAt ?? new Date()).toISOString(),
979
+ release: this.release,
980
+ migration_id: this.migrationId,
981
+ sdk_version: this.sdkVersion,
982
+ error: {
983
+ // extra.errorType overrides the class-name default — needed for
984
+ // business-invariant violations, where many different dashboard
985
+ // rules share one Error class (BusinessInvariantViolation) and must
986
+ // still fingerprint into separate issues (see invariantRules.ts).
987
+ type: extra?.errorType ?? (err.constructor?.name ?? err.name ?? 'Error'),
988
+ message: err.message,
989
+ stack: (0, stack_js_1.parseStack)(err),
990
+ },
991
+ request: ctx
992
+ ? {
993
+ method: ctx.request.method,
994
+ path: ctx.request.path,
995
+ headers: (0, stack_js_1.stripHeaders)(ctx.request.headers),
996
+ body: ctx.request.body,
997
+ auth_claims: ctx.request.authClaims,
998
+ }
999
+ : { method: '', path: '', headers: {}, body: '' },
1000
+ response: extra?.response,
1001
+ data_footprint: { queries: ctx?.footprint ?? [] },
1002
+ outbound_calls: ctx?.outboundCalls ?? [],
1003
+ };
1004
+ // Concurrency cohort: what else was in flight (or just finished) when
1005
+ // this error happened — the raw material for race-condition replay.
1006
+ // The key is omitted entirely when the request was alone.
1007
+ const cohort = ctx ? (0, registry_js_1.snapshotCohort)(ctx) : [];
1008
+ if (cohort.length > 0) {
1009
+ payload.concurrent_requests = cohort;
1010
+ }
1011
+ this.transport.enqueue(payload);
1012
+ }
1013
+ // Attach process-level handlers. AsyncLocalStorage context survives into
1014
+ // both handlers for errors from async continuations (verified on Node 22),
1015
+ // so even these captures carry request context when one exists. Contexts
1016
+ // already captured (e.g. by the http emit wrapper's sync-throw catch) are
1017
+ // skipped to avoid double-reporting.
1018
+ attachProcessHandlers() {
1019
+ process.on('uncaughtException', (err) => {
1020
+ const ctx = context_js_1.contextStorage.getStore();
1021
+ if (!ctx?.captured)
1022
+ this.capture('unhandled_exception', err, ctx);
1023
+ // Allow flush before exit.
1024
+ void this.transport.flush().finally(() => process.exit(1));
1025
+ });
1026
+ process.on('unhandledRejection', (reason) => {
1027
+ const err = reason instanceof Error ? reason : new Error(String(reason));
1028
+ const ctx = context_js_1.contextStorage.getStore();
1029
+ if (!ctx?.captured)
1030
+ this.capture('unhandled_exception', err, ctx);
1031
+ });
1032
+ }
1033
+ // Run any operation — a queue consumer, a script step, anything without
1034
+ // an HTTP request — inside a capture context: errors are captured (then
1035
+ // rethrown, so the caller's error flow is unchanged), and footprints /
1036
+ // outbound calls recorded inside land on the capture.
1037
+ async runWithContext(info, fn) {
1038
+ return this.runInContext('unhandled_exception', info, fn);
1039
+ }
1040
+ // Background-job wrapper: like runWithContext, but failures ingest with
1041
+ // the job_failure trigger so they're distinguishable on the dashboard.
1042
+ async runJob(name, fn) {
1043
+ return this.runInContext('job_failure', { name }, fn);
1044
+ }
1045
+ async runInContext(trigger, info, fn) {
1046
+ const ctx = {
1047
+ traceId: (0, stack_js_1.generateTraceId)(),
1048
+ capturedAt: new Date(),
1049
+ request: {
1050
+ method: info.method ?? 'JOB',
1051
+ path: info.path ?? info.name ?? 'task',
1052
+ headers: info.headers ?? {},
1053
+ body: info.body ?? '',
1054
+ },
1055
+ footprint: [],
1056
+ outboundCalls: [],
1057
+ businessInvariantHits: [],
1058
+ writeCountDriftHits: [],
1059
+ };
1060
+ (0, registry_js_1.registerContext)(ctx);
1061
+ try {
1062
+ return await context_js_1.contextStorage.run(ctx, () => fn());
1063
+ }
1064
+ catch (err) {
1065
+ if (err instanceof Error)
1066
+ this.capture(trigger, err, ctx);
1067
+ throw err;
1068
+ }
1069
+ finally {
1070
+ (0, registry_js_1.completeContext)(ctx);
1071
+ }
1072
+ }
1073
+ // Record a data-footprint entry from ANY data layer (knex, raw SQL, an
1074
+ // in-house DAO): one call per query site is what makes an error
1075
+ // REPRODUCIBLE rather than just visible. `model` maps to a table via the
1076
+ // tenant's naming strategy, exactly like a Prisma model name.
1077
+ //
1078
+ // Falls back to ctxByRequest when AsyncLocalStorage's active store is
1079
+ // gone — the SAME condition errorHandler() already falls back for (see
1080
+ // ctxByRequest's own doc comment: a validation middleware like celebrate
1081
+ // resuming through its own internal .then(next).catch(next) chain, not
1082
+ // through the continuation ALS was tracking). errorHandler() gets this
1083
+ // fallback for free because Express always hands error middleware the
1084
+ // raw req — a manual call site like this one doesn't have req unless the
1085
+ // caller passes it, which is why rawRequest is a second, optional
1086
+ // parameter here: pass the same value setAuthClaims-style code already
1087
+ // has on hand (Express's req, or any framework's raw request object) and
1088
+ // a footprint recorded from inside a request whose ALS context broke
1089
+ // stops silently vanishing. Still a silent no-op only when BOTH lookups
1090
+ // find nothing — never throws, same as every other capture path.
1091
+ recordFootprint(query, rawRequest) {
1092
+ const ctx = context_js_1.contextStorage.getStore() ?? (rawRequest !== undefined ? ctxByRequest.get(rawRequest) : undefined);
1093
+ ctx?.footprint.push(query);
1094
+ }
1095
+ patchOutbound() {
1096
+ const sdk = this;
1097
+ const allowHosts = this.outboundOpts?.allowHosts;
1098
+ const denyHosts = this.outboundOpts?.denyHosts;
1099
+ function shouldRecord(host) {
1100
+ if (denyHosts?.some(h => host.includes(h)))
1101
+ return false;
1102
+ if (allowHosts && !allowHosts.some(h => host.includes(h)))
1103
+ return false;
1104
+ return true;
1105
+ }
1106
+ // Patch global fetch.
1107
+ const originalFetch = globalThis.fetch;
1108
+ if (typeof originalFetch === 'function') {
1109
+ globalThis.fetch = async function patchedFetch(input, init) {
1110
+ // Parse the URL before the fetch so the failure path below can name
1111
+ // the destination even when the call never connects.
1112
+ const url = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input.url);
1113
+ const started = performance.now();
1114
+ let res;
1115
+ try {
1116
+ res = await originalFetch(input, init);
1117
+ }
1118
+ catch (err) {
1119
+ // A failed call (timeout, DNS, refused connection) is often THE
1120
+ // cause of the error being captured — record it, then re-throw so
1121
+ // application behavior is untouched.
1122
+ const ctx = context_js_1.contextStorage.getStore();
1123
+ if (ctx && shouldRecord(url.host)) {
1124
+ const name = err?.name;
1125
+ ctx.outboundCalls.push({
1126
+ host: url.host,
1127
+ method: (init?.method ?? 'GET').toUpperCase(),
1128
+ path: url.pathname + url.search,
1129
+ status: 0,
1130
+ duration_ms: Math.round(performance.now() - started),
1131
+ outcome: name === 'TimeoutError' || name === 'AbortError' ? 'timeout' : 'network_error',
1132
+ recorded_for_replay: true,
1133
+ });
1134
+ }
1135
+ throw err;
1136
+ }
1137
+ const ctx = context_js_1.contextStorage.getStore();
1138
+ if (ctx) {
1139
+ const host = url.host;
1140
+ if (shouldRecord(host)) {
1141
+ const ct = res.headers.get('content-type') ?? '';
1142
+ let responseBody;
1143
+ if (ct.includes('json') || ct.includes('text')) {
1144
+ try {
1145
+ const clone = res.clone();
1146
+ const text = await clone.text();
1147
+ responseBody = text.slice(0, 64 * 1024);
1148
+ }
1149
+ catch { /* ignore */ }
1150
+ }
1151
+ ctx.outboundCalls.push({
1152
+ host,
1153
+ method: (init?.method ?? 'GET').toUpperCase(),
1154
+ path: url.pathname + url.search,
1155
+ status: res.status,
1156
+ response_body: responseBody,
1157
+ duration_ms: Math.round(performance.now() - started),
1158
+ outcome: 'success',
1159
+ recorded_for_replay: true,
1160
+ });
1161
+ }
1162
+ }
1163
+ return res;
1164
+ };
1165
+ }
1166
+ }
1167
+ // Wired up dynamically onto [Symbol.asyncDispose] in the constructor —
1168
+ // see the comment there for why this is a plain private method instead
1169
+ // of a computed class member.
1170
+ async disposeInternal() {
1171
+ this.rulesPoller.stop();
1172
+ this.idempotencySettingsPoller.stop();
1173
+ this.inferenceReporter.stop();
1174
+ await this.inferenceReporter.flush();
1175
+ this.distributionBaselinePoller.stop();
1176
+ this.distributionDriftReporter.stop();
1177
+ await this.distributionDriftReporter.flush();
1178
+ this.writeCountBaselinePoller.stop();
1179
+ this.writeCountDriftReporter.stop();
1180
+ await this.writeCountDriftReporter.flush();
1181
+ this.mongoSchemaReporter?.stop();
1182
+ this.transport.stop();
1183
+ await this.transport.flush();
1184
+ }
1185
+ }
1186
+ exports.Reprova = Reprova;
1187
+ // Auto-setup http_5xx hook for Express responses.
1188
+ function wrapResponse(res, sdk) {
1189
+ if (typeof res.json !== 'function')
1190
+ return; // non-standard res (tests, mocks)
1191
+ const originalJson = res.json.bind(res);
1192
+ res.json = function (body) {
1193
+ if (res.statusCode >= 500) {
1194
+ const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
1195
+ sdk.captureHttp5xx(null, res.statusCode, bodyStr.slice(0, 16 * 1024));
1196
+ }
1197
+ // Checked on every response, not just failures — an N+1 request usually
1198
+ // returns 200 while doing far more DB round-trips than it should.
1199
+ sdk.captureNPlusOneIfAny();
1200
+ sdk.captureRetryStormIfAny();
1201
+ sdk.captureBusinessInvariantViolationsIfAny();
1202
+ sdk.captureWriteCountDriftIfAny();
1203
+ // Suspended: idempotency checks (both the key-mismatch and the
1204
+ // duplicate-read detector live inside checkIdempotencyIfAny) are
1205
+ // paused. Not removed: the method, its settings poller, and the
1206
+ // control-plane endpoints are all still intact; uncomment this line
1207
+ // to re-enable both checks.
1208
+ // sdk.checkIdempotencyIfAny(body);
1209
+ sdk.checkDistributionDriftIfAny(body);
1210
+ // Replay mode only: capture() stashed a detector's {trigger, type,
1211
+ // message} on the context instead of transporting it (no live control
1212
+ // plane to report to). Surface it here, as a header rather than folding
1213
+ // it into the body, so the CLI's verdict comparison can read it without
1214
+ // disturbing the app's real response shape — checked only as a fallback
1215
+ // when the body itself carries no error (parseReplayResponse in
1216
+ // cli/src/lib/verdict.ts), since a thrown exception's own
1217
+ // replayErrorHandler/nestExceptionFilter body always takes priority.
1218
+ if (sdk.replayMode) {
1219
+ const ctx = context_js_1.contextStorage.getStore();
1220
+ if (ctx?.replayDetection && typeof res.setHeader === 'function') {
1221
+ try {
1222
+ res.setHeader('X-Reprova-Replay-Detection', JSON.stringify(ctx.replayDetection));
1223
+ }
1224
+ catch {
1225
+ /* never break the response */
1226
+ }
1227
+ }
1228
+ }
1229
+ return originalJson(body);
1230
+ };
1231
+ }