@reprova/sdk 0.5.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 (145) hide show
  1. package/README.md +75 -0
  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 +18 -0
  35. package/dist/context.d.ts.map +1 -1
  36. package/dist/context.js.map +1 -1
  37. package/dist/distributionBaselinePoller.d.ts +36 -0
  38. package/dist/distributionBaselinePoller.d.ts.map +1 -0
  39. package/dist/distributionBaselinePoller.js +84 -0
  40. package/dist/distributionBaselinePoller.js.map +1 -0
  41. package/dist/distributionDrift.d.ts +41 -0
  42. package/dist/distributionDrift.d.ts.map +1 -0
  43. package/dist/distributionDrift.js +161 -0
  44. package/dist/distributionDrift.js.map +1 -0
  45. package/dist/drizzle.d.ts.map +1 -1
  46. package/dist/drizzle.js +10 -4
  47. package/dist/drizzle.js.map +1 -1
  48. package/dist/frameworkAdapter.d.ts +5 -0
  49. package/dist/frameworkAdapter.d.ts.map +1 -0
  50. package/dist/frameworkAdapter.js +2 -0
  51. package/dist/frameworkAdapter.js.map +1 -0
  52. package/dist/httpFetch.d.ts +13 -0
  53. package/dist/httpFetch.d.ts.map +1 -0
  54. package/dist/httpFetch.js +54 -0
  55. package/dist/httpFetch.js.map +1 -0
  56. package/dist/idempotency.d.ts +26 -0
  57. package/dist/idempotency.d.ts.map +1 -0
  58. package/dist/idempotency.js +124 -0
  59. package/dist/idempotency.js.map +1 -0
  60. package/dist/idempotencySettingsPoller.d.ts +23 -0
  61. package/dist/idempotencySettingsPoller.d.ts.map +1 -0
  62. package/dist/idempotencySettingsPoller.js +68 -0
  63. package/dist/idempotencySettingsPoller.js.map +1 -0
  64. package/dist/index.d.ts +30 -5
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +17 -3
  67. package/dist/index.js.map +1 -1
  68. package/dist/invariantInference.d.ts +28 -0
  69. package/dist/invariantInference.d.ts.map +1 -0
  70. package/dist/invariantInference.js +87 -0
  71. package/dist/invariantInference.js.map +1 -0
  72. package/dist/invariantRules.d.ts +16 -0
  73. package/dist/invariantRules.d.ts.map +1 -0
  74. package/dist/invariantRules.js +104 -0
  75. package/dist/invariantRules.js.map +1 -0
  76. package/dist/knex.d.ts.map +1 -1
  77. package/dist/knex.js +1 -0
  78. package/dist/knex.js.map +1 -1
  79. package/dist/kysely.d.ts.map +1 -1
  80. package/dist/kysely.js +2 -1
  81. package/dist/kysely.js.map +1 -1
  82. package/dist/legacyPrisma.d.ts +6 -0
  83. package/dist/legacyPrisma.d.ts.map +1 -0
  84. package/dist/legacyPrisma.js +128 -0
  85. package/dist/legacyPrisma.js.map +1 -0
  86. package/dist/mikroorm.d.ts +2 -0
  87. package/dist/mikroorm.d.ts.map +1 -0
  88. package/dist/mikroorm.js +72 -0
  89. package/dist/mikroorm.js.map +1 -0
  90. package/dist/mongoSchemaReporter.d.ts +23 -0
  91. package/dist/mongoSchemaReporter.d.ts.map +1 -0
  92. package/dist/mongoSchemaReporter.js +82 -0
  93. package/dist/mongoSchemaReporter.js.map +1 -0
  94. package/dist/mongoose.d.ts +48 -0
  95. package/dist/mongoose.d.ts.map +1 -0
  96. package/dist/mongoose.js +331 -0
  97. package/dist/mongoose.js.map +1 -0
  98. package/dist/nPlusOne.d.ts +52 -0
  99. package/dist/nPlusOne.d.ts.map +1 -0
  100. package/dist/nPlusOne.js +0 -0
  101. package/dist/nPlusOne.js.map +1 -0
  102. package/dist/nest.d.ts +13 -0
  103. package/dist/nest.d.ts.map +1 -1
  104. package/dist/nest.js +39 -0
  105. package/dist/nest.js.map +1 -1
  106. package/dist/nextjs.d.ts +27 -0
  107. package/dist/nextjs.d.ts.map +1 -0
  108. package/dist/nextjs.js +178 -0
  109. package/dist/nextjs.js.map +1 -0
  110. package/dist/prisma.d.ts +21 -5
  111. package/dist/prisma.d.ts.map +1 -1
  112. package/dist/prisma.js +247 -22
  113. package/dist/prisma.js.map +1 -1
  114. package/dist/proto.gen.d.ts +10 -2
  115. package/dist/proto.gen.d.ts.map +1 -1
  116. package/dist/registry.d.ts +1 -0
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +24 -0
  119. package/dist/registry.js.map +1 -1
  120. package/dist/retryStorm.d.ts +42 -0
  121. package/dist/retryStorm.d.ts.map +1 -0
  122. package/dist/retryStorm.js +65 -0
  123. package/dist/retryStorm.js.map +1 -0
  124. package/dist/rulesPoller.d.ts +26 -0
  125. package/dist/rulesPoller.d.ts.map +1 -0
  126. package/dist/rulesPoller.js +74 -0
  127. package/dist/rulesPoller.js.map +1 -0
  128. package/dist/sdk.d.ts +48 -7
  129. package/dist/sdk.d.ts.map +1 -1
  130. package/dist/sdk.js +619 -47
  131. package/dist/sdk.js.map +1 -1
  132. package/dist/sequelize.d.ts.map +1 -1
  133. package/dist/sequelize.js +4 -3
  134. package/dist/sequelize.js.map +1 -1
  135. package/dist/stack.d.ts.map +1 -1
  136. package/dist/stack.js +6 -3
  137. package/dist/stack.js.map +1 -1
  138. package/dist/transport.d.ts.map +1 -1
  139. package/dist/transport.js +2 -1
  140. package/dist/transport.js.map +1 -1
  141. package/dist/typeorm.d.ts +1 -0
  142. package/dist/typeorm.d.ts.map +1 -1
  143. package/dist/typeorm.js +133 -1
  144. package/dist/typeorm.js.map +1 -1
  145. package/package.json +14 -5
package/dist/sdk.js CHANGED
@@ -1,11 +1,40 @@
1
1
  import http from 'node:http';
2
2
  import https from 'node:https';
3
+ import { readFileSync } from 'node:fs';
4
+ import { httpFetch } from './httpFetch.js';
3
5
  import { contextStorage } from './context.js';
4
6
  import { registerContext, completeContext, snapshotCohort } from './registry.js';
5
7
  import { BatchTransport } from './transport.js';
6
8
  import { parseStack, generateTraceId, stripHeaders, captureHeaders } from './stack.js';
7
9
  import { createPrismaExtension } from './prisma.js';
8
- import { createNestExceptionFilter } from './nest.js';
10
+ import { instrumentLegacyPrismaClient } from './legacyPrisma.js';
11
+ import { createNestExceptionFilter, createNestContextInterceptor } from './nest.js';
12
+ import { detectNPlusOne, PotentialNPlusOneQuery } from './nPlusOne.js';
13
+ import { detectRetryStorm, PotentialRetryStorm } from './retryStorm.js';
14
+ import { BusinessInvariantViolation } from './invariantRules.js';
15
+ import { RulesPoller } from './rulesPoller.js';
16
+ import { DEFAULT_IDEMPOTENCY_SETTINGS, HashMismatchCache, IdempotencyKeyMismatch, DuplicateReadMismatch, findIdempotencyKey, extractIdentity, hashResponseBody, } from './idempotency.js';
17
+ import { IdempotencySettingsPoller } from './idempotencySettingsPoller.js';
18
+ import { InferenceReporter } from './invariantInference.js';
19
+ import { DistributionDriftReporter, ValueDistributionDrift, WriteCountDrift, extractTopLevelNumericFields, isDrift } from './distributionDrift.js';
20
+ import { DistributionBaselinePoller } from './distributionBaselinePoller.js';
21
+ import { reprovaMongoosePlugin, instrumentAlreadyCompiledModels } from './mongoose.js';
22
+ import { MongoSchemaReporter } from './mongoSchemaReporter.js';
23
+ // Missing/malformed snapshot degrades to "no seeded settings" — replay mode
24
+ // still runs (context establishment, http_5xx/unhandled_exception capture
25
+ // all work independent of this), just without drift/invariant/idempotency
26
+ // detection firing, exactly like today. Never a hard failure.
27
+ function loadReplaySettingsSnapshot() {
28
+ const path = process.env.REPROVA_REPLAY_SETTINGS_PATH;
29
+ if (!path)
30
+ return undefined;
31
+ try {
32
+ return JSON.parse(readFileSync(path, 'utf8'));
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
9
38
  let instance = null;
10
39
  // Zero-code-change async error capture: Express 4 discards the promise an
11
40
  // async handler returns, so a rejection never reaches error middleware unless
@@ -25,7 +54,18 @@ const patchedLayerProtos = new WeakSet();
25
54
  // auth-only convention — req.user sometimes carries a broader profile.
26
55
  // Apps using neither (a custom property, a non-Express framework) still
27
56
  // need the explicit setAuthClaims() escape hatch from context.ts.
28
- function detectAuthClaims(req) {
57
+ function detectAuthClaims(req, customExtractor) {
58
+ if (customExtractor) {
59
+ try {
60
+ const claims = customExtractor(req);
61
+ if (claims !== undefined)
62
+ return claims;
63
+ }
64
+ catch {
65
+ // A misbehaving adapter must never break request capture — fall
66
+ // through to the built-in convention below.
67
+ }
68
+ }
29
69
  return req.auth ?? req.user;
30
70
  }
31
71
  // refreshRequestSnapshot re-reads body and auth claims from the live req at
@@ -34,18 +74,31 @@ function detectAuthClaims(req) {
34
74
  // req.body/req.auth/req.user — by the time anything is captured, all three
35
75
  // are final. Mutating ctx.request in place keeps cohort snapshots consistent
36
76
  // too.
37
- function refreshRequestSnapshot(ctx) {
77
+ function refreshRequestSnapshot(ctx, customExtractor) {
38
78
  const req = ctx.rawRequest;
39
79
  if (!req)
40
80
  return;
41
81
  if (req.body !== undefined) {
42
82
  ctx.request.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? '');
43
83
  }
44
- const claims = detectAuthClaims(req);
84
+ const claims = detectAuthClaims(req, customExtractor);
45
85
  if (claims !== undefined && ctx.request.authClaims === undefined) {
46
86
  ctx.request.authClaims = claims;
47
87
  }
48
88
  }
89
+ // Fallback for errorHandler(): the request's context, keyed by the raw
90
+ // req/IncomingMessage object rather than AsyncLocalStorage. Needed because
91
+ // ALS's active store can be lost partway through a middleware chain if a
92
+ // middleware BEFORE the route handler resumes its continuation through a
93
+ // promise/microtask chain not properly linked back to the async context ALS
94
+ // was tracking — confirmed live against `celebrate` (a common Express/Joi
95
+ // validation middleware): a route wrapped in celebrate(...) reaches its
96
+ // actual handler with contextStorage.getStore() already undefined, even
97
+ // though the SAME app's non-celebrate routes keep it intact throughout. The
98
+ // req object itself has no such problem — it's the same single reference
99
+ // for the whole request regardless of which continuation mechanism resumed
100
+ // execution, so a direct lookup on it survives whatever broke ALS.
101
+ const ctxByRequest = new WeakMap();
49
102
  // Universal HTTP instrumentation: wrap the 'request' event on node's own
50
103
  // http/https servers, so EVERY Node app — vanilla http.createServer, Koa,
51
104
  // Fastify, Express — runs its request handling inside a Reprova context.
@@ -64,13 +117,14 @@ function instrumentHttpServers() {
64
117
  const originalEmit = proto.emit;
65
118
  proto.emit = function (event, ...args) {
66
119
  const sdk = instance;
67
- if (event !== 'request' || !sdk || sdk.disabled) {
120
+ if (event !== 'request' || !sdk || (sdk.disabled && !sdk.replayMode)) {
68
121
  return originalEmit.apply(this, [event, ...args]);
69
122
  }
70
123
  const req = args[0];
71
124
  const res = args[1];
72
125
  const ctx = contextFromIncoming(req);
73
126
  registerContext(ctx);
127
+ ctxByRequest.set(req, ctx);
74
128
  res.on('finish', () => {
75
129
  // Silent 5xx: the handler responded 5xx without throwing. Vanilla
76
130
  // responses have no res.json to wrap, so the check runs at finish.
@@ -104,6 +158,14 @@ function contextFromIncoming(req) {
104
158
  // keep raw url
105
159
  }
106
160
  const traceId = req.headers['traceparent'] ?? generateTraceId();
161
+ // Write the resolved value back onto the raw request's own headers (even
162
+ // when it was just generated, not client-sent) so any framework that
163
+ // builds ITS OWN request representation from these headers — notably
164
+ // Next.js App Router, which constructs a fresh Fetch API Request rather
165
+ // than reusing this IncomingMessage — carries the same traceId forward.
166
+ // That's what lets findContextByTraceId recover THIS exact context later,
167
+ // even across a request-object boundary ALS itself doesn't survive.
168
+ req.headers['traceparent'] = traceId;
107
169
  return {
108
170
  traceId,
109
171
  capturedAt: new Date(),
@@ -115,6 +177,8 @@ function contextFromIncoming(req) {
115
177
  },
116
178
  footprint: [],
117
179
  outboundCalls: [],
180
+ businessInvariantHits: [],
181
+ writeCountDriftHits: [],
118
182
  rawRequest: req,
119
183
  };
120
184
  }
@@ -229,18 +293,66 @@ function patchLayerPrototype(app) {
229
293
  export class Reprova {
230
294
  opts;
231
295
  transport;
296
+ rulesPoller;
297
+ idempotencySettingsPoller;
298
+ inferenceReporter;
299
+ distributionDriftReporter;
300
+ distributionBaselinePoller;
301
+ writeCountDriftReporter;
302
+ writeCountBaselinePoller;
303
+ // Unlike the other reporters above, there's nothing to point this at
304
+ // until an app actually calls instrumentMongoose(mongooseInstance) — apps
305
+ // that never do (every SQL-dialect app) pay/leak nothing.
306
+ mongoSchemaReporter;
307
+ instrumentedMongooseInstances = new WeakSet();
308
+ idempotencyKeyCache = new HashMismatchCache(DEFAULT_IDEMPOTENCY_SETTINGS.max_keys, DEFAULT_IDEMPOTENCY_SETTINGS.ttl_ms);
309
+ duplicateReadCache = new HashMismatchCache(DEFAULT_IDEMPOTENCY_SETTINGS.max_keys, DEFAULT_IDEMPOTENCY_SETTINGS.duplicate_read_window_ms);
232
310
  release;
233
- sdkVersion = '0.4.0';
311
+ sdkVersion = '0.6.0';
234
312
  disabled;
313
+ // True when running under the CLI's `repro run`/`repro test` replay
314
+ // (REPROVA_REPLAY=1). Independent of `disabled`: replay apps have no live
315
+ // DSN (disabled stays true — no network transport, no live polling ever),
316
+ // but still need detection/instrumentation to run against a locally
317
+ // seeded settings snapshot instead of doing nothing. See capture(),
318
+ // static init(), and requestHandler() for where this is checked.
319
+ replayMode;
235
320
  migrationId = 'unknown';
321
+ authClaimsExtractor;
236
322
  outboundOpts;
323
+ n1Threshold;
324
+ retryStormThreshold;
237
325
  constructor(opts) {
238
326
  this.opts = opts;
239
327
  this.disabled = !opts.dsn;
328
+ this.replayMode = process.env.REPROVA_REPLAY === '1';
240
329
  if (this.disabled) {
241
- // Inert transport: constructed so internal wiring stays uniform, but
242
- // never started and capture() never enqueues into it.
330
+ // Inert transport/pollers: constructed so internal wiring stays
331
+ // uniform, but never started capture() never enqueues (outside
332
+ // replay mode), no rule ever evaluates true (getInvariantRules() stays
333
+ // empty), and idempotency checks stay off (getIdempotencySettings()
334
+ // stays at the disabled-safe default) UNLESS replay mode seeds them
335
+ // from a one-time snapshot below (still never .start()'d — no live
336
+ // polling from inside a replayed app either way).
243
337
  this.transport = new BatchTransport({ endpoint: 'http://disabled.invalid/v1/ingest', apiKey: '' });
338
+ this.rulesPoller = new RulesPoller({ endpoint: 'http://disabled.invalid/v1/business-invariant-rules', apiKey: '' });
339
+ this.idempotencySettingsPoller = new IdempotencySettingsPoller({ endpoint: 'http://disabled.invalid/v1/settings/idempotency', apiKey: '' });
340
+ this.inferenceReporter = new InferenceReporter({ endpoint: 'http://disabled.invalid/v1/inferred-invariants/observe', apiKey: '' });
341
+ this.distributionDriftReporter = new DistributionDriftReporter({ endpoint: 'http://disabled.invalid/v1/distribution-baselines/observe', apiKey: '' });
342
+ this.distributionBaselinePoller = new DistributionBaselinePoller({ endpoint: 'http://disabled.invalid/v1/distribution-baselines', apiKey: '' });
343
+ this.writeCountDriftReporter = new DistributionDriftReporter({ endpoint: 'http://disabled.invalid/v1/write-count-baselines/observe', apiKey: '' });
344
+ this.writeCountBaselinePoller = new DistributionBaselinePoller({ endpoint: 'http://disabled.invalid/v1/write-count-baselines', apiKey: '' });
345
+ if (this.replayMode) {
346
+ const snapshot = loadReplaySettingsSnapshot();
347
+ if (snapshot?.rules)
348
+ this.rulesPoller.seed(snapshot.rules);
349
+ if (snapshot?.distributionBaselines)
350
+ this.distributionBaselinePoller.seed(snapshot.distributionBaselines);
351
+ if (snapshot?.writeCountBaselines)
352
+ this.writeCountBaselinePoller.seed(snapshot.writeCountBaselines);
353
+ if (snapshot?.idempotencySettings)
354
+ this.idempotencySettingsPoller.seed(snapshot.idempotencySettings);
355
+ }
244
356
  }
245
357
  else {
246
358
  const url = new URL(opts.dsn);
@@ -248,13 +360,78 @@ export class Reprova {
248
360
  const endpoint = `${url.protocol}//${url.host}/v1/ingest`;
249
361
  this.transport = new BatchTransport({ endpoint, apiKey });
250
362
  this.transport.start();
363
+ this.rulesPoller = new RulesPoller({
364
+ endpoint: `${url.protocol}//${url.host}/v1/business-invariant-rules`,
365
+ apiKey,
366
+ intervalMs: opts.rulesRefreshIntervalMs,
367
+ });
368
+ this.rulesPoller.start();
369
+ this.idempotencySettingsPoller = new IdempotencySettingsPoller({
370
+ endpoint: `${url.protocol}//${url.host}/v1/settings/idempotency`,
371
+ apiKey,
372
+ intervalMs: opts.rulesRefreshIntervalMs,
373
+ });
374
+ this.idempotencySettingsPoller.start();
375
+ this.inferenceReporter = new InferenceReporter({
376
+ endpoint: `${url.protocol}//${url.host}/v1/inferred-invariants/observe`,
377
+ apiKey,
378
+ });
379
+ this.inferenceReporter.start();
380
+ this.distributionDriftReporter = new DistributionDriftReporter({
381
+ endpoint: `${url.protocol}//${url.host}/v1/distribution-baselines/observe`,
382
+ apiKey,
383
+ });
384
+ this.distributionDriftReporter.start();
385
+ this.distributionBaselinePoller = new DistributionBaselinePoller({
386
+ endpoint: `${url.protocol}//${url.host}/v1/distribution-baselines`,
387
+ apiKey,
388
+ intervalMs: opts.rulesRefreshIntervalMs,
389
+ });
390
+ this.distributionBaselinePoller.start();
391
+ this.writeCountDriftReporter = new DistributionDriftReporter({
392
+ endpoint: `${url.protocol}//${url.host}/v1/write-count-baselines/observe`,
393
+ apiKey,
394
+ });
395
+ this.writeCountDriftReporter.start();
396
+ this.writeCountBaselinePoller = new DistributionBaselinePoller({
397
+ endpoint: `${url.protocol}//${url.host}/v1/write-count-baselines`,
398
+ apiKey,
399
+ intervalMs: opts.rulesRefreshIntervalMs,
400
+ });
401
+ this.writeCountBaselinePoller.start();
251
402
  }
252
403
  this.release = opts.release;
253
404
  this.outboundOpts = opts.recordOutbound;
405
+ this.n1Threshold = opts.n1Threshold ?? 5;
406
+ this.retryStormThreshold = opts.retryStormThreshold ?? 3;
407
+ // Opts into `await using sdk = Reprova.init(...)` (TC39 explicit
408
+ // resource management) on runtimes that have it, WITHOUT declaring a
409
+ // [Symbol.asyncDispose] class member — that syntax requires the
410
+ // computed key to be a `unique symbol` type, which in turn requires
411
+ // every CONSUMER's own tsconfig to include the esnext.disposable lib
412
+ // just to type-check a feature they may never use (most projects'
413
+ // `lib` arrays don't include it). Assigning it dynamically here is a
414
+ // plain runtime property write, invisible to the type checker
415
+ // entirely, so it can't break anyone's build regardless of their
416
+ // `lib` setting — identical behavior for anyone actually using `await
417
+ // using`, zero effect on everyone else.
418
+ const asyncDisposeSymbol = Symbol.asyncDispose;
419
+ if (asyncDisposeSymbol) {
420
+ this[asyncDisposeSymbol] = () => this.disposeInternal();
421
+ }
422
+ }
423
+ // Live-cached rules for the tenant, refreshed on rulesRefreshIntervalMs —
424
+ // read by the Prisma extension on every query, never re-fetched per-query.
425
+ getInvariantRules() {
426
+ return this.rulesPoller.getEnabledRules();
254
427
  }
255
428
  static init(opts) {
256
429
  instance = new Reprova(opts);
257
- if (!instance.disabled) {
430
+ // replayMode runs this block too even though disabled stays true — a
431
+ // replayed app still needs context establishment (so detectors have
432
+ // something to attach to) and instrumentation, just with no live
433
+ // transport/polling underneath it.
434
+ if (!instance.disabled || instance.replayMode) {
258
435
  instance.patchOutbound();
259
436
  if (opts.instrumentHttp !== false)
260
437
  instrumentHttpServers();
@@ -265,6 +442,25 @@ export class Reprova {
265
442
  }
266
443
  static getInstance() { return instance; }
267
444
  setMigrationId(id) { this.migrationId = id; }
445
+ // Generic per-framework extension point — see frameworkAdapter.ts for why
446
+ // this is two seams (auth detection, migration_id) rather than a bigger
447
+ // plugin system. resolveMigrationId is awaited once, at registration time;
448
+ // call registerFrameworkAdapter again (or setMigrationId directly) if it
449
+ // can change during the process's lifetime.
450
+ registerFrameworkAdapter(adapter) {
451
+ if (adapter.extractAuthClaims)
452
+ this.authClaimsExtractor = adapter.extractAuthClaims;
453
+ if (adapter.resolveMigrationId) {
454
+ adapter
455
+ .resolveMigrationId()
456
+ .then((id) => this.setMigrationId(id))
457
+ .catch(() => {
458
+ // Never let a broken migration-id resolver affect anything else —
459
+ // migrationId simply stays whatever it already was ('unknown' by
460
+ // default).
461
+ });
462
+ }
463
+ }
268
464
  // One-call Prisma integration. Splices the footprint-recording extension
269
465
  // onto an existing PrismaClient (in place, preserving its identity) and
270
466
  // best-effort reads the latest applied migration into migration_id.
@@ -277,48 +473,167 @@ export class Reprova {
277
473
  // $extends returns a NEW client and the shared singleton must keep
278
474
  // recording. Await it so migration_id is set before the first request.
279
475
  async instrumentPrisma(client, opts) {
280
- if (this.disabled)
476
+ // Found live while proving the replay-mode detector fix end to end: this
477
+ // gate originally matched every other disabled-mode no-op, but replay
478
+ // mode needs the Prisma extension actually installed — that's the ONLY
479
+ // thing that runs applyInvariantRules()/observeWriteCountDrift() at all.
480
+ // Nothing else in this method touches opts.dsn or the network, so
481
+ // widening the gate is safe.
482
+ if (this.disabled && !this.replayMode)
281
483
  return;
282
484
  const c = client;
283
- // 1. Footprint capture: extend, then copy the extended client's behavior
284
- // back onto the original so every importer of the singleton records.
285
- const ext = createPrismaExtension(opts?.dmmf);
286
- Object.assign(c, c.$extends(ext));
485
+ // 1. Footprint capture. Two paths, same recording logic underneath
486
+ // (recordPrismaOperation, shared by both): $extends (Prisma's
487
+ // Client Extensions API, 4.16+) is the modern, official hook — extend,
488
+ // then copy the extended client's behavior back onto the original so
489
+ // every importer of the singleton records. Older clients have no
490
+ // $extends (and no $use, its deprecated predecessor) at all —
491
+ // confirmed live against a real @prisma/client@2.0.0-beta.4 — so for
492
+ // those, legacyPrisma.ts monkey-patches each model delegate's own CRUD
493
+ // methods directly instead, the same shape this SDK already uses for
494
+ // TypeORM's QueryRunner and Mongoose's pre-compiled models.
495
+ if (typeof c.$extends === 'function') {
496
+ const ext = createPrismaExtension(opts?.dmmf, () => this.getInvariantRules(),
497
+ // Suspended: invariant inference ("Suggested invariants") is paused —
498
+ // pass undefined instead of this.inferenceReporter so
499
+ // observeInferredInvariants never runs. Not removed: the reporter,
500
+ // the observation logic, and the control-plane endpoints are all
501
+ // still intact; pass this.inferenceReporter here again to re-enable.
502
+ undefined, this.writeCountDriftReporter, this.writeCountBaselinePoller);
503
+ Object.assign(c, c.$extends(ext));
504
+ }
505
+ else {
506
+ instrumentLegacyPrismaClient(client, {
507
+ dmmf: opts?.dmmf,
508
+ getRules: () => this.getInvariantRules(),
509
+ inferenceReporter: undefined, // suspended, same as the modern path above
510
+ writeCountDriftReporter: this.writeCountDriftReporter,
511
+ writeCountBaselinePoller: this.writeCountBaselinePoller,
512
+ });
513
+ }
287
514
  // 2. migration_id: best-effort. Absent table (migrations not run) or any
288
515
  // query error leaves it at the 'unknown' default — never fatal at boot.
289
- // Two dialects of the same query, tried in order: SQL Server has no
290
- // LIMIT (TOP goes right after SELECT instead), and the SDK deliberately
291
- // has no notion of "which dialect" — it never imports @prisma/client, so
292
- // it can't inspect the datasource provider; trying both is simpler than
293
- // adding a dialect option just for this one query.
516
+ // Three tries, in order: modern `prisma migrate`'s `_prisma_migrations`
517
+ // (Postgres/MySQL dialect, then SQL Server's TOP-instead-of-LIMIT
518
+ // dialect — the SDK deliberately has no notion of "which dialect" since
519
+ // it never imports @prisma/client), then the OLD experimental Prisma
520
+ // Migrate's `_Migration` table (singular, capitalized confirmed live
521
+ // against a real @prisma/client@2.0.0-beta.4 project: that era tracked
522
+ // migrations in `_Migration(name, status, finished_at)`, not
523
+ // `_prisma_migrations`, and only reached "success" rows count). Also
524
+ // tries `client.raw` (that same ancient client's pre-$queryRaw tagged-
525
+ // template method — confirmed live to behave identically for a SELECT)
526
+ // when `$queryRaw` isn't there at all.
294
527
  if (opts?.readMigrationId !== false) {
295
- let rows = [];
296
- try {
297
- rows = await c.$queryRaw `
298
- SELECT migration_name FROM _prisma_migrations ORDER BY finished_at DESC LIMIT 1
299
- `;
300
- }
301
- catch {
528
+ const rawQuery = c.$queryRaw ?? c.raw;
529
+ if (rawQuery) {
530
+ let rows = [];
302
531
  try {
303
- rows = await c.$queryRaw `
304
- SELECT TOP 1 migration_name FROM _prisma_migrations ORDER BY finished_at DESC
532
+ rows = await rawQuery.bind(c) `
533
+ SELECT migration_name FROM _prisma_migrations ORDER BY finished_at DESC LIMIT 1
305
534
  `;
306
535
  }
307
536
  catch {
308
- // migration_id stays 'unknown'
537
+ try {
538
+ rows = await rawQuery.bind(c) `
539
+ SELECT TOP 1 migration_name FROM _prisma_migrations ORDER BY finished_at DESC
540
+ `;
541
+ }
542
+ catch {
543
+ try {
544
+ const legacyRows = await rawQuery.bind(c) `
545
+ SELECT name FROM _Migration WHERE status = 'MigrationSuccess' ORDER BY finished_at DESC LIMIT 1
546
+ `;
547
+ rows = legacyRows.map((r) => ({ migration_name: r.name }));
548
+ }
549
+ catch {
550
+ // migration_id stays 'unknown'
551
+ }
552
+ }
553
+ }
554
+ if (rows[0]?.migration_name) {
555
+ this.setMigrationId(rows[0].migration_name);
309
556
  }
310
557
  }
311
- if (rows[0]?.migration_name) {
312
- this.setMigrationId(rows[0].migration_name);
313
- }
314
558
  }
315
559
  }
560
+ // One-call Mongoose integration: installs the footprint plugin (unless
561
+ // this exact mongoose instance already has it — repeat calls are harmless)
562
+ // and starts a MongoSchemaReporter pointed at it, so reference edges
563
+ // (ref: declarations across every compiled model) get reported to the
564
+ // control plane without the app doing any manual config. No-op when the
565
+ // SDK is disabled, like instrumentPrisma/recordFootprint.
566
+ instrumentMongoose(mongooseInstance) {
567
+ // Footprint capture (this half) needs to run in replay mode too — it's
568
+ // what feeds the adapter-generic detectors (n_plus_one_query,
569
+ // retry_storm) that don't depend on a live DSN at all. The schema
570
+ // reporter below genuinely needs one (it POSTs discovered reference
571
+ // edges to a live control plane), so THAT half stays disabled-only,
572
+ // replay mode included — there's no dsn to build its endpoint from.
573
+ if (this.disabled && !this.replayMode)
574
+ return;
575
+ if (!this.instrumentedMongooseInstances.has(mongooseInstance)) {
576
+ this.instrumentedMongooseInstances.add(mongooseInstance);
577
+ mongooseInstance.plugin(reprovaMongoosePlugin);
578
+ }
579
+ // Covers models compiled BEFORE this call — mongoose.plugin() above
580
+ // only reaches schemas compiled after it, so an app whose own bootstrap
581
+ // already compiled a model earlier (or that simply calls
582
+ // instrumentMongoose() late) would otherwise capture nothing for it.
583
+ instrumentAlreadyCompiledModels(mongooseInstance);
584
+ if (this.disabled)
585
+ return; // replay mode, no live dsn — nothing to report edges to
586
+ this.mongoSchemaReporter?.stop();
587
+ const url = new URL(this.opts.dsn);
588
+ const apiKey = url.pathname.slice(1);
589
+ this.mongoSchemaReporter = new MongoSchemaReporter({
590
+ endpoint: `${url.protocol}//${url.host}/v1/mongo-schema/observe`,
591
+ apiKey,
592
+ mongooseInstance,
593
+ });
594
+ this.mongoSchemaReporter.start();
595
+ }
596
+ // Escape hatch for apps using the raw MongoDB driver (no Mongoose, hence
597
+ // no schema registry to auto-discover ref: edges from). Mirrors
598
+ // registerFrameworkAdapter's philosophy — a seam, not a plugin system:
599
+ // supply the same {from_collection, from_field, to_collection, array}
600
+ // shape by hand instead of it being walked from schema.paths. Feeds the
601
+ // same reporting path a Mongoose app's auto-discovery would, just without
602
+ // the "model" indirection (raw-driver apps only ever deal in collection
603
+ // names, so edges here are already fully resolved — no to_model lookup
604
+ // needed control-plane-side).
605
+ registerReferenceEdges(edges) {
606
+ if (this.disabled)
607
+ return;
608
+ const url = new URL(this.opts.dsn);
609
+ const apiKey = url.pathname.slice(1);
610
+ const endpoint = `${url.protocol}//${url.host}/v1/mongo-schema/observe`;
611
+ // A raw-driver edge already names its target by collection, not by a
612
+ // Mongoose model — reusing each target collection as its own "model"
613
+ // name still resolves correctly, because RecordMongoSchema resolves
614
+ // ToModel against this SAME report's Models list, and a self-
615
+ // referential entry (model name == collection name) makes every edge
616
+ // resolve to itself with zero extra server-side logic.
617
+ const collections = new Set(edges.map((e) => e.toCollection));
618
+ const models = [...collections].map((collection) => ({ model: collection, collection }));
619
+ const wireEdges = edges.map((e) => ({ from_collection: e.fromCollection, from_field: e.fromField, to_model: e.toCollection, array: e.array }));
620
+ void httpFetch(endpoint, {
621
+ method: 'POST',
622
+ headers: { 'Content-Type': 'application/json', 'X-Reprova-Key': apiKey },
623
+ body: JSON.stringify({ models, edges: wireEdges }),
624
+ }).catch(() => {
625
+ // Best-effort, same as every other reporter — never break the host app.
626
+ });
627
+ }
316
628
  // One-call Express integration (Sentry-style): mounts the request context
317
629
  // middleware (repositioned to run before everything, wherever setup is
318
630
  // called), the error-capture middleware (kept last even if routes are
319
631
  // registered later), the http_5xx response hook, and async rejection
320
632
  // forwarding. Equivalent to the manual requestHandler/errorHandler/
321
633
  // wrapResponse wiring — same capture payloads, one line.
634
+ // ExpressAppLike (see its own doc comment above) rather than express's
635
+ // own Application/Express type — either real type satisfies this
636
+ // trivially, regardless of which @types/express version produced it.
322
637
  setupExpress(app) {
323
638
  app.use(this.requestHandler());
324
639
  const router = app._router;
@@ -356,7 +671,7 @@ export class Reprova {
356
671
  keepErrorHandlerLast(req.app);
357
672
  }
358
673
  // http_5xx-without-throw hook rides along automatically.
359
- if (!this.disabled)
674
+ if (!this.disabled || this.replayMode)
360
675
  wrapResponse(res, this);
361
676
  // With HTTP-server instrumentation active (init default), a context
362
677
  // for this very request already exists — don't shadow it with a
@@ -375,25 +690,33 @@ export class Reprova {
375
690
  path: req.path,
376
691
  headers: captureHeaders(req.headers),
377
692
  body: typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? ''),
378
- authClaims: detectAuthClaims(req),
693
+ authClaims: detectAuthClaims(req, this.authClaimsExtractor),
379
694
  },
380
695
  footprint: [],
381
696
  outboundCalls: [],
697
+ businessInvariantHits: [],
698
+ writeCountDriftHits: [],
382
699
  rawRequest: req,
383
700
  };
384
701
  // Track this context so a concurrent request's capture can see it
385
702
  // (and, briefly after completion, that it recently overlapped).
386
703
  registerContext(ctx);
704
+ ctxByRequest.set(req, ctx);
387
705
  const done = () => completeContext(ctx);
388
706
  res.on('finish', done);
389
707
  res.on('close', done);
390
708
  contextStorage.run(ctx, next);
391
709
  };
392
710
  }
393
- // Express error middleware: captures unhandled_exception.
711
+ // Express error middleware: captures unhandled_exception. Falls back to
712
+ // ctxByRequest when the AsyncLocalStorage store has gone missing by the
713
+ // time an error reaches here (see ctxByRequest's own comment) — a route
714
+ // wrapped in some validation middlewares would otherwise capture nothing
715
+ // but a switch to the generic http_5xx fallback: a real error with a real
716
+ // stack, silently downgraded to a synthetic "HTTP 500 response".
394
717
  errorHandler() {
395
718
  return (err, req, res, next) => {
396
- const ctx = contextStorage.getStore();
719
+ const ctx = contextStorage.getStore() ?? ctxByRequest.get(req);
397
720
  if (ctx && err instanceof Error) {
398
721
  this.capture('unhandled_exception', err, ctx);
399
722
  }
@@ -421,16 +744,42 @@ export class Reprova {
421
744
  },
422
745
  });
423
746
  }
747
+ // Recovers request context across Nest's own pipe resolution — see
748
+ // createNestContextInterceptor's doc comment in nest.ts for the full story
749
+ // (found and confirmed against a real NestJS 7 app: any route with a
750
+ // @Body() DTO reached its service/repository code with
751
+ // contextStorage.getStore() already empty). resolveContext reuses the SAME
752
+ // req-keyed side channel recordFootprint() already falls back to — the
753
+ // interceptor is just the one place in a Nest app that reliably has the
754
+ // raw request AND runs early enough to re-anchor context before any pipe
755
+ // or controller code executes, which no ORM adapter can do on its own
756
+ // (none of them have the request object in scope).
757
+ nestContextInterceptor() {
758
+ return createNestContextInterceptor({
759
+ resolveContext: (req) => (req !== undefined ? ctxByRequest.get(req) : undefined) ?? contextStorage.getStore(),
760
+ runInContext: (ctx, fn) => contextStorage.run(ctx, fn),
761
+ });
762
+ }
424
763
  // One-call NestJS setup, the setupExpress of the Nest world: registers the
425
- // exception filter and (when given a Prisma client) wires automatic data
426
- // footprints + migration_id in the same breath. The whole integration:
764
+ // exception filter and context-recovery interceptor, and (when given a
765
+ // Prisma client) wires automatic data footprints + migration_id in the
766
+ // same breath. The whole integration:
427
767
  // const sdk = Reprova.init({ dsn, release });
428
768
  // const app = await NestFactory.create(AppModule);
429
769
  // await sdk.setupNest(app, { prisma: app.get(PrismaService), dmmf: Prisma.dmmf });
430
770
  // Other ORMs stay their usual one line (installTypeOrmSubscriber(dataSource),
431
- // installKnexHooks(knex), ...) — they aren't Reprova-instance methods.
771
+ // installKnexHooks(knex), ...) — they aren't Reprova-instance methods, but
772
+ // the interceptor registered here benefits ALL of them equally: it fixes
773
+ // context before any ORM code runs, not per-adapter.
774
+ //
775
+ // The interceptor is registered here, first, deliberately — Nest merges
776
+ // multiple useGlobalInterceptors() calls in registration order, and
777
+ // registering ours before any app-defined interceptor makes it the
778
+ // outermost wrapper, covering the whole remaining chain (every other
779
+ // interceptor, every pipe, the controller itself).
432
780
  async setupNest(app, opts) {
433
781
  app.useGlobalFilters(this.nestExceptionFilter());
782
+ app.useGlobalInterceptors(this.nestContextInterceptor());
434
783
  if (opts?.prisma) {
435
784
  await this.instrumentPrisma(opts.prisma, { dmmf: opts.dmmf });
436
785
  }
@@ -450,12 +799,169 @@ export class Reprova {
450
799
  response: { status: statusCode, body: responseBody.slice(0, 16 * 1024) },
451
800
  });
452
801
  }
802
+ // Called by response hook on every response, success or failure. Unlike
803
+ // captureHttp5xx this isn't gated on status code — an N+1 request usually
804
+ // returns 200 while doing far more DB round-trips than it should, which is
805
+ // exactly the case with no exception and no 5xx to hook into otherwise.
806
+ // Skips (like captureHttp5xx) once this request already has a capture, so
807
+ // a request that both throws AND has an N+1 pattern doesn't get reported
808
+ // twice — the thrown error takes priority. One capture PER offending
809
+ // (model, op) pair, not one aggregating all of them: that keeps each
810
+ // pattern's fingerprint stable and independently trackable as its own
811
+ // issue, rather than lumping unrelated repeated-query patterns together
812
+ // or producing a message whose composition varies request to request.
813
+ captureNPlusOneIfAny() {
814
+ if (this.n1Threshold <= 0)
815
+ return;
816
+ const ctx = contextStorage.getStore();
817
+ if (!ctx || ctx.captured)
818
+ return;
819
+ for (const finding of detectNPlusOne(ctx.footprint, this.n1Threshold)) {
820
+ this.capture('n_plus_one_query', new PotentialNPlusOneQuery(finding), ctx);
821
+ }
822
+ }
823
+ // Same shape and same reasoning as captureNPlusOneIfAny: not gated on
824
+ // status code (the OUTER request can easily still return 200 — an
825
+ // upstream dependency getting hammered by retries doesn't necessarily
826
+ // fail the request that triggered them), skipped once this request
827
+ // already has a capture, one capture per offending (host, method, path)
828
+ // group rather than one aggregating every group found.
829
+ captureRetryStormIfAny() {
830
+ if (this.retryStormThreshold <= 0)
831
+ return;
832
+ const ctx = contextStorage.getStore();
833
+ if (!ctx || ctx.captured)
834
+ return;
835
+ for (const finding of detectRetryStorm(ctx.outboundCalls, this.retryStormThreshold)) {
836
+ this.capture('retry_storm', new PotentialRetryStorm(finding), ctx);
837
+ }
838
+ }
839
+ // Drains business-invariant violations the Prisma extension recorded
840
+ // during this request (prisma.ts — the only place the actual row values a
841
+ // rule needs are available). One capture per violated rule, same
842
+ // one-per-pattern reasoning as captureNPlusOneIfAny; errorType is set
843
+ // explicitly per rule so different rules fingerprint into different
844
+ // issues despite sharing one Error class.
845
+ captureBusinessInvariantViolationsIfAny() {
846
+ const ctx = contextStorage.getStore();
847
+ if (!ctx || ctx.captured)
848
+ return;
849
+ for (const hit of ctx.businessInvariantHits) {
850
+ this.capture('business_invariant', new BusinessInvariantViolation(hit.message), ctx, {
851
+ errorType: `InvariantViolation:${hit.ruleId}`,
852
+ });
853
+ }
854
+ }
855
+ // Drains write-count-drift hits the Prisma extension recorded during this
856
+ // request (prisma.ts's observeWriteCountDrift — the only place a bulk
857
+ // write's affected-row count is available). Same one-per-hit shape as
858
+ // captureBusinessInvariantViolationsIfAny; errorType is per (route,
859
+ // operation, model) so distinct bulk-write anomalies fingerprint into
860
+ // distinct issues despite sharing one Error class.
861
+ captureWriteCountDriftIfAny() {
862
+ const ctx = contextStorage.getStore();
863
+ if (!ctx || ctx.captured)
864
+ return;
865
+ for (const hit of ctx.writeCountDriftHits) {
866
+ this.capture('write_count_drift', new WriteCountDrift(`${hit.route} ${hit.operation} on ${hit.model} affected an unusual number of rows`), ctx, { errorType: `WriteCountDrift:${hit.route}:${hit.operation}:${hit.model}` });
867
+ }
868
+ }
869
+ // Unlike the other capture*IfAny methods, this one needs the actual
870
+ // response body — not something already sitting on ctx — so it's called
871
+ // directly from wrapResponse's res.json override with body in hand,
872
+ // rather than generically after the fact. Checks the idempotency-key
873
+ // detector first; if that already captured (one per request, same as
874
+ // every other trigger), the duplicate-read detector is skipped rather
875
+ // than risk a second capture for the same response.
876
+ checkIdempotencyIfAny(body) {
877
+ const ctx = contextStorage.getStore();
878
+ if (!ctx || ctx.captured)
879
+ return;
880
+ const settings = this.idempotencySettingsPoller.getSettings();
881
+ const req = ctx.rawRequest;
882
+ const routePattern = req?.route?.path;
883
+ const method = ctx.request.method;
884
+ if (settings.key_check_enabled) {
885
+ const key = findIdempotencyKey(ctx.request.headers, settings.header_names);
886
+ if (key) {
887
+ this.idempotencyKeyCache.configure(settings.max_keys, settings.ttl_ms);
888
+ const hash = hashResponseBody(body);
889
+ const signature = `${method} ${routePattern ?? ''} ${key}`;
890
+ if (this.idempotencyKeyCache.checkAndRecord(signature, hash)) {
891
+ this.capture('idempotency_key_mismatch', new IdempotencyKeyMismatch(`Idempotent ${method} request${routePattern ? ` to ${routePattern}` : ''} returned a different response for the same idempotency key`), ctx, { errorType: 'IdempotencyKeyMismatch' });
892
+ return;
893
+ }
894
+ }
895
+ }
896
+ if (settings.duplicate_read_enabled && method === 'GET') {
897
+ const identity = extractIdentity(ctx.request.authClaims);
898
+ if (identity) {
899
+ const rawUrl = req?.originalUrl ?? req?.url ?? '';
900
+ const query = rawUrl.split('?')[1] ?? '';
901
+ this.duplicateReadCache.configure(settings.max_keys, settings.duplicate_read_window_ms);
902
+ const hash = hashResponseBody(body);
903
+ const signature = `${routePattern ?? ctx.request.path} ${query} identity:${identity}`;
904
+ if (this.duplicateReadCache.checkAndRecord(signature, hash)) {
905
+ this.capture('duplicate_read_mismatch', new DuplicateReadMismatch(`Duplicate GET${routePattern ? ` to ${routePattern}` : ''} within ${settings.duplicate_read_window_ms}ms returned a different result`), ctx, { errorType: 'DuplicateReadMismatch' });
906
+ }
907
+ }
908
+ }
909
+ }
910
+ // Numeric-only v1 slice (see distributionDrift.ts's module doc). Always
911
+ // records every observed value into the local reporter regardless of
912
+ // whether drift detection itself is enabled — the baseline needs to keep
913
+ // accumulating so it's actually populated by the time an admin flips the
914
+ // dashboard toggle on, rather than starting from zero at that moment.
915
+ // Only the CAPTURE side is gated on the polled enabled flag.
916
+ checkDistributionDriftIfAny(body) {
917
+ const ctx = contextStorage.getStore();
918
+ if (!ctx)
919
+ return;
920
+ const req = ctx.rawRequest;
921
+ const routePattern = req?.route?.path;
922
+ if (!routePattern)
923
+ return; // no stable key to group on — a resolved path would leak a real id into the key
924
+ const route = `${ctx.request.method} ${routePattern}`;
925
+ const fields = extractTopLevelNumericFields(body);
926
+ if (Object.keys(fields).length === 0)
927
+ return;
928
+ for (const [field, value] of Object.entries(fields)) {
929
+ this.distributionDriftReporter.record(route, field, value);
930
+ }
931
+ if (!this.distributionBaselinePoller.isEnabled() || ctx.captured)
932
+ return;
933
+ for (const [field, value] of Object.entries(fields)) {
934
+ const baseline = this.distributionBaselinePoller.getBaseline(route, field);
935
+ if (baseline && isDrift(value, baseline)) {
936
+ this.capture('value_distribution_drift', new ValueDistributionDrift(`${route} field "${field}" is far outside its observed baseline range`), ctx, { errorType: `ValueDistributionDrift:${route}:${field}` });
937
+ return; // one capture max per response, same as every other *IfAny
938
+ }
939
+ }
940
+ }
453
941
  capture(trigger, err, ctx, extra) {
454
- if (this.disabled)
942
+ if (this.disabled) {
943
+ // Replay mode: there's no live control plane to report to (the app is
944
+ // running against an anonymized disposable DB, not production), so
945
+ // instead of a network capture, stash the same {type, message} a real
946
+ // occurrence would have stored — extra?.errorType ?? the class name,
947
+ // exactly matching the payload's own error.type resolution below —
948
+ // onto the context. wrapResponse surfaces it via a response header for
949
+ // the CLI's verdict comparison to read instead of the transport.
950
+ // First-wins, same one-capture-per-response contract every other
951
+ // trigger already follows.
952
+ if (this.replayMode && ctx && !ctx.captured) {
953
+ ctx.captured = true;
954
+ ctx.replayDetection = {
955
+ trigger,
956
+ type: extra?.errorType ?? (err.constructor?.name ?? err.name ?? 'Error'),
957
+ message: err.message,
958
+ };
959
+ }
455
960
  return;
961
+ }
456
962
  if (ctx) {
457
963
  ctx.captured = true;
458
- refreshRequestSnapshot(ctx);
964
+ refreshRequestSnapshot(ctx, this.authClaimsExtractor);
459
965
  }
460
966
  const payload = {
461
967
  trigger: trigger,
@@ -465,7 +971,11 @@ export class Reprova {
465
971
  migration_id: this.migrationId,
466
972
  sdk_version: this.sdkVersion,
467
973
  error: {
468
- type: err.constructor?.name ?? err.name ?? 'Error',
974
+ // extra.errorType overrides the class-name default — needed for
975
+ // business-invariant violations, where many different dashboard
976
+ // rules share one Error class (BusinessInvariantViolation) and must
977
+ // still fingerprint into separate issues (see invariantRules.ts).
978
+ type: extra?.errorType ?? (err.constructor?.name ?? err.name ?? 'Error'),
469
979
  message: err.message,
470
980
  stack: parseStack(err),
471
981
  },
@@ -535,6 +1045,8 @@ export class Reprova {
535
1045
  },
536
1046
  footprint: [],
537
1047
  outboundCalls: [],
1048
+ businessInvariantHits: [],
1049
+ writeCountDriftHits: [],
538
1050
  };
539
1051
  registerContext(ctx);
540
1052
  try {
@@ -552,10 +1064,24 @@ export class Reprova {
552
1064
  // Record a data-footprint entry from ANY data layer (knex, raw SQL, an
553
1065
  // in-house DAO): one call per query site is what makes an error
554
1066
  // REPRODUCIBLE rather than just visible. `model` maps to a table via the
555
- // tenant's naming strategy, exactly like a Prisma model name. Silent
556
- // no-op outside a capture context.
557
- recordFootprint(query) {
558
- contextStorage.getStore()?.footprint.push(query);
1067
+ // tenant's naming strategy, exactly like a Prisma model name.
1068
+ //
1069
+ // Falls back to ctxByRequest when AsyncLocalStorage's active store is
1070
+ // gone — the SAME condition errorHandler() already falls back for (see
1071
+ // ctxByRequest's own doc comment: a validation middleware like celebrate
1072
+ // resuming through its own internal .then(next).catch(next) chain, not
1073
+ // through the continuation ALS was tracking). errorHandler() gets this
1074
+ // fallback for free because Express always hands error middleware the
1075
+ // raw req — a manual call site like this one doesn't have req unless the
1076
+ // caller passes it, which is why rawRequest is a second, optional
1077
+ // parameter here: pass the same value setAuthClaims-style code already
1078
+ // has on hand (Express's req, or any framework's raw request object) and
1079
+ // a footprint recorded from inside a request whose ALS context broke
1080
+ // stops silently vanishing. Still a silent no-op only when BOTH lookups
1081
+ // find nothing — never throws, same as every other capture path.
1082
+ recordFootprint(query, rawRequest) {
1083
+ const ctx = contextStorage.getStore() ?? (rawRequest !== undefined ? ctxByRequest.get(rawRequest) : undefined);
1084
+ ctx?.footprint.push(query);
559
1085
  }
560
1086
  patchOutbound() {
561
1087
  const sdk = this;
@@ -629,7 +1155,21 @@ export class Reprova {
629
1155
  };
630
1156
  }
631
1157
  }
632
- async [Symbol.asyncDispose]() {
1158
+ // Wired up dynamically onto [Symbol.asyncDispose] in the constructor —
1159
+ // see the comment there for why this is a plain private method instead
1160
+ // of a computed class member.
1161
+ async disposeInternal() {
1162
+ this.rulesPoller.stop();
1163
+ this.idempotencySettingsPoller.stop();
1164
+ this.inferenceReporter.stop();
1165
+ await this.inferenceReporter.flush();
1166
+ this.distributionBaselinePoller.stop();
1167
+ this.distributionDriftReporter.stop();
1168
+ await this.distributionDriftReporter.flush();
1169
+ this.writeCountBaselinePoller.stop();
1170
+ this.writeCountDriftReporter.stop();
1171
+ await this.writeCountDriftReporter.flush();
1172
+ this.mongoSchemaReporter?.stop();
633
1173
  this.transport.stop();
634
1174
  await this.transport.flush();
635
1175
  }
@@ -644,6 +1184,38 @@ export function wrapResponse(res, sdk) {
644
1184
  const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
645
1185
  sdk.captureHttp5xx(null, res.statusCode, bodyStr.slice(0, 16 * 1024));
646
1186
  }
1187
+ // Checked on every response, not just failures — an N+1 request usually
1188
+ // returns 200 while doing far more DB round-trips than it should.
1189
+ sdk.captureNPlusOneIfAny();
1190
+ sdk.captureRetryStormIfAny();
1191
+ sdk.captureBusinessInvariantViolationsIfAny();
1192
+ sdk.captureWriteCountDriftIfAny();
1193
+ // Suspended: idempotency checks (both the key-mismatch and the
1194
+ // duplicate-read detector live inside checkIdempotencyIfAny) are
1195
+ // paused. Not removed: the method, its settings poller, and the
1196
+ // control-plane endpoints are all still intact; uncomment this line
1197
+ // to re-enable both checks.
1198
+ // sdk.checkIdempotencyIfAny(body);
1199
+ sdk.checkDistributionDriftIfAny(body);
1200
+ // Replay mode only: capture() stashed a detector's {trigger, type,
1201
+ // message} on the context instead of transporting it (no live control
1202
+ // plane to report to). Surface it here, as a header rather than folding
1203
+ // it into the body, so the CLI's verdict comparison can read it without
1204
+ // disturbing the app's real response shape — checked only as a fallback
1205
+ // when the body itself carries no error (parseReplayResponse in
1206
+ // cli/src/lib/verdict.ts), since a thrown exception's own
1207
+ // replayErrorHandler/nestExceptionFilter body always takes priority.
1208
+ if (sdk.replayMode) {
1209
+ const ctx = contextStorage.getStore();
1210
+ if (ctx?.replayDetection && typeof res.setHeader === 'function') {
1211
+ try {
1212
+ res.setHeader('X-Reprova-Replay-Detection', JSON.stringify(ctx.replayDetection));
1213
+ }
1214
+ catch {
1215
+ /* never break the response */
1216
+ }
1217
+ }
1218
+ }
647
1219
  return originalJson(body);
648
1220
  };
649
1221
  }