@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.
- package/README.md +170 -8
- package/dist/cjs/context.js +28 -0
- package/dist/cjs/distributionBaselinePoller.js +87 -0
- package/dist/cjs/distributionDrift.js +170 -0
- package/dist/cjs/drizzle.js +204 -0
- package/dist/cjs/frameworkAdapter.js +2 -0
- package/dist/cjs/httpFetch.js +59 -0
- package/dist/cjs/idempotency.js +132 -0
- package/dist/cjs/idempotencySettingsPoller.js +71 -0
- package/dist/cjs/index.js +96 -0
- package/dist/cjs/invariantInference.js +90 -0
- package/dist/cjs/invariantRules.js +142 -0
- package/dist/cjs/knex.js +86 -0
- package/dist/cjs/kysely.js +175 -0
- package/dist/cjs/legacyPrisma.js +130 -0
- package/dist/cjs/mikroorm.js +74 -0
- package/dist/cjs/mongoSchemaReporter.js +85 -0
- package/dist/cjs/mongoose.js +335 -0
- package/dist/cjs/nPlusOne.js +0 -0
- package/dist/cjs/nest.js +136 -0
- package/dist/cjs/nextjs.js +180 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/prisma.js +363 -0
- package/dist/cjs/proto.gen.js +4 -0
- package/dist/cjs/registry.js +100 -0
- package/dist/cjs/retryStorm.js +70 -0
- package/dist/cjs/rulesPoller.js +77 -0
- package/dist/cjs/sdk.js +1231 -0
- package/dist/cjs/sequelize.js +108 -0
- package/dist/cjs/shape.js +45 -0
- package/dist/cjs/stack.js +51 -0
- package/dist/cjs/transport.js +71 -0
- package/dist/cjs/typeorm.js +200 -0
- package/dist/context.d.ts +32 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +18 -0
- package/dist/context.js.map +1 -1
- package/dist/distributionBaselinePoller.d.ts +36 -0
- package/dist/distributionBaselinePoller.d.ts.map +1 -0
- package/dist/distributionBaselinePoller.js +84 -0
- package/dist/distributionBaselinePoller.js.map +1 -0
- package/dist/distributionDrift.d.ts +41 -0
- package/dist/distributionDrift.d.ts.map +1 -0
- package/dist/distributionDrift.js +161 -0
- package/dist/distributionDrift.js.map +1 -0
- package/dist/drizzle.d.ts +22 -0
- package/dist/drizzle.d.ts.map +1 -1
- package/dist/drizzle.js +129 -1
- package/dist/drizzle.js.map +1 -1
- package/dist/frameworkAdapter.d.ts +5 -0
- package/dist/frameworkAdapter.d.ts.map +1 -0
- package/dist/frameworkAdapter.js +2 -0
- package/dist/frameworkAdapter.js.map +1 -0
- package/dist/httpFetch.d.ts +13 -0
- package/dist/httpFetch.d.ts.map +1 -0
- package/dist/httpFetch.js +54 -0
- package/dist/httpFetch.js.map +1 -0
- package/dist/idempotency.d.ts +26 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +124 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/idempotencySettingsPoller.d.ts +23 -0
- package/dist/idempotencySettingsPoller.d.ts.map +1 -0
- package/dist/idempotencySettingsPoller.js +68 -0
- package/dist/idempotencySettingsPoller.js.map +1 -0
- package/dist/index.d.ts +33 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -4
- package/dist/index.js.map +1 -1
- package/dist/invariantInference.d.ts +28 -0
- package/dist/invariantInference.d.ts.map +1 -0
- package/dist/invariantInference.js +87 -0
- package/dist/invariantInference.js.map +1 -0
- package/dist/invariantRules.d.ts +16 -0
- package/dist/invariantRules.d.ts.map +1 -0
- package/dist/invariantRules.js +104 -0
- package/dist/invariantRules.js.map +1 -0
- package/dist/knex.d.ts.map +1 -1
- package/dist/knex.js +1 -0
- package/dist/knex.js.map +1 -1
- package/dist/kysely.d.ts.map +1 -1
- package/dist/kysely.js +2 -1
- package/dist/kysely.js.map +1 -1
- package/dist/legacyPrisma.d.ts +6 -0
- package/dist/legacyPrisma.d.ts.map +1 -0
- package/dist/legacyPrisma.js +128 -0
- package/dist/legacyPrisma.js.map +1 -0
- package/dist/mikroorm.d.ts +2 -0
- package/dist/mikroorm.d.ts.map +1 -0
- package/dist/mikroorm.js +72 -0
- package/dist/mikroorm.js.map +1 -0
- package/dist/mongoSchemaReporter.d.ts +23 -0
- package/dist/mongoSchemaReporter.d.ts.map +1 -0
- package/dist/mongoSchemaReporter.js +82 -0
- package/dist/mongoSchemaReporter.js.map +1 -0
- package/dist/mongoose.d.ts +48 -0
- package/dist/mongoose.d.ts.map +1 -0
- package/dist/mongoose.js +331 -0
- package/dist/mongoose.js.map +1 -0
- package/dist/nPlusOne.d.ts +52 -0
- package/dist/nPlusOne.d.ts.map +1 -0
- package/dist/nPlusOne.js +0 -0
- package/dist/nPlusOne.js.map +1 -0
- package/dist/nest.d.ts +31 -0
- package/dist/nest.d.ts.map +1 -0
- package/dist/nest.js +133 -0
- package/dist/nest.js.map +1 -0
- package/dist/nextjs.d.ts +27 -0
- package/dist/nextjs.d.ts.map +1 -0
- package/dist/nextjs.js +178 -0
- package/dist/nextjs.js.map +1 -0
- package/dist/prisma.d.ts +21 -5
- package/dist/prisma.d.ts.map +1 -1
- package/dist/prisma.js +247 -22
- package/dist/prisma.js.map +1 -1
- package/dist/proto.gen.d.ts +19 -1
- package/dist/proto.gen.d.ts.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +24 -0
- package/dist/registry.js.map +1 -1
- package/dist/retryStorm.d.ts +42 -0
- package/dist/retryStorm.d.ts.map +1 -0
- package/dist/retryStorm.js +65 -0
- package/dist/retryStorm.js.map +1 -0
- package/dist/rulesPoller.d.ts +26 -0
- package/dist/rulesPoller.d.ts.map +1 -0
- package/dist/rulesPoller.js +74 -0
- package/dist/rulesPoller.js.map +1 -0
- package/dist/sdk.d.ts +53 -6
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +663 -34
- package/dist/sdk.js.map +1 -1
- package/dist/sequelize.d.ts.map +1 -1
- package/dist/sequelize.js +4 -3
- package/dist/sequelize.js.map +1 -1
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +6 -3
- package/dist/stack.js.map +1 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +2 -1
- package/dist/transport.js.map +1 -1
- package/dist/typeorm.d.ts +1 -0
- package/dist/typeorm.d.ts.map +1 -1
- package/dist/typeorm.js +133 -1
- package/dist/typeorm.js.map +1 -1
- package/package.json +20 -5
package/dist/sdk.js
CHANGED
|
@@ -1,10 +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';
|
|
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
|
+
}
|
|
8
38
|
let instance = null;
|
|
9
39
|
// Zero-code-change async error capture: Express 4 discards the promise an
|
|
10
40
|
// async handler returns, so a rejection never reaches error middleware unless
|
|
@@ -16,22 +46,59 @@ const kAsyncWrapped = Symbol('reprova.asyncWrapped');
|
|
|
16
46
|
const patchedApps = new WeakSet();
|
|
17
47
|
// Layer prototypes already patched — one per physical express copy.
|
|
18
48
|
const patchedLayerProtos = new WeakSet();
|
|
49
|
+
// Best-effort auto-detection, no app code changes required: req.auth is the
|
|
50
|
+
// express-oauth2-jwt-bearer / Auth0 convention; req.user is what Passport
|
|
51
|
+
// and the overwhelming majority of hand-rolled Express JWT middleware
|
|
52
|
+
// assigns instead (`req.user = decoded` right after verifying the token).
|
|
53
|
+
// req.auth wins when both are present since it's the more specific,
|
|
54
|
+
// auth-only convention — req.user sometimes carries a broader profile.
|
|
55
|
+
// Apps using neither (a custom property, a non-Express framework) still
|
|
56
|
+
// need the explicit setAuthClaims() escape hatch from context.ts.
|
|
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
|
+
}
|
|
69
|
+
return req.auth ?? req.user;
|
|
70
|
+
}
|
|
19
71
|
// refreshRequestSnapshot re-reads body and auth claims from the live req at
|
|
20
72
|
// capture time. The request middleware runs at the very front of the stack
|
|
21
73
|
// (before body parsers and auth middleware), so its eager snapshot can miss
|
|
22
|
-
// req.body/req.auth — by the time anything is captured,
|
|
23
|
-
// Mutating ctx.request in place keeps cohort snapshots consistent
|
|
24
|
-
|
|
74
|
+
// req.body/req.auth/req.user — by the time anything is captured, all three
|
|
75
|
+
// are final. Mutating ctx.request in place keeps cohort snapshots consistent
|
|
76
|
+
// too.
|
|
77
|
+
function refreshRequestSnapshot(ctx, customExtractor) {
|
|
25
78
|
const req = ctx.rawRequest;
|
|
26
79
|
if (!req)
|
|
27
80
|
return;
|
|
28
81
|
if (req.body !== undefined) {
|
|
29
82
|
ctx.request.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? '');
|
|
30
83
|
}
|
|
31
|
-
|
|
32
|
-
|
|
84
|
+
const claims = detectAuthClaims(req, customExtractor);
|
|
85
|
+
if (claims !== undefined && ctx.request.authClaims === undefined) {
|
|
86
|
+
ctx.request.authClaims = claims;
|
|
33
87
|
}
|
|
34
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();
|
|
35
102
|
// Universal HTTP instrumentation: wrap the 'request' event on node's own
|
|
36
103
|
// http/https servers, so EVERY Node app — vanilla http.createServer, Koa,
|
|
37
104
|
// Fastify, Express — runs its request handling inside a Reprova context.
|
|
@@ -50,13 +117,14 @@ function instrumentHttpServers() {
|
|
|
50
117
|
const originalEmit = proto.emit;
|
|
51
118
|
proto.emit = function (event, ...args) {
|
|
52
119
|
const sdk = instance;
|
|
53
|
-
if (event !== 'request' || !sdk || sdk.disabled) {
|
|
120
|
+
if (event !== 'request' || !sdk || (sdk.disabled && !sdk.replayMode)) {
|
|
54
121
|
return originalEmit.apply(this, [event, ...args]);
|
|
55
122
|
}
|
|
56
123
|
const req = args[0];
|
|
57
124
|
const res = args[1];
|
|
58
125
|
const ctx = contextFromIncoming(req);
|
|
59
126
|
registerContext(ctx);
|
|
127
|
+
ctxByRequest.set(req, ctx);
|
|
60
128
|
res.on('finish', () => {
|
|
61
129
|
// Silent 5xx: the handler responded 5xx without throwing. Vanilla
|
|
62
130
|
// responses have no res.json to wrap, so the check runs at finish.
|
|
@@ -90,6 +158,14 @@ function contextFromIncoming(req) {
|
|
|
90
158
|
// keep raw url
|
|
91
159
|
}
|
|
92
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;
|
|
93
169
|
return {
|
|
94
170
|
traceId,
|
|
95
171
|
capturedAt: new Date(),
|
|
@@ -101,6 +177,8 @@ function contextFromIncoming(req) {
|
|
|
101
177
|
},
|
|
102
178
|
footprint: [],
|
|
103
179
|
outboundCalls: [],
|
|
180
|
+
businessInvariantHits: [],
|
|
181
|
+
writeCountDriftHits: [],
|
|
104
182
|
rawRequest: req,
|
|
105
183
|
};
|
|
106
184
|
}
|
|
@@ -215,18 +293,66 @@ function patchLayerPrototype(app) {
|
|
|
215
293
|
export class Reprova {
|
|
216
294
|
opts;
|
|
217
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);
|
|
218
310
|
release;
|
|
219
|
-
sdkVersion = '0.
|
|
311
|
+
sdkVersion = '0.6.0';
|
|
220
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;
|
|
221
320
|
migrationId = 'unknown';
|
|
321
|
+
authClaimsExtractor;
|
|
222
322
|
outboundOpts;
|
|
323
|
+
n1Threshold;
|
|
324
|
+
retryStormThreshold;
|
|
223
325
|
constructor(opts) {
|
|
224
326
|
this.opts = opts;
|
|
225
327
|
this.disabled = !opts.dsn;
|
|
328
|
+
this.replayMode = process.env.REPROVA_REPLAY === '1';
|
|
226
329
|
if (this.disabled) {
|
|
227
|
-
// Inert transport: constructed so internal wiring stays
|
|
228
|
-
// never started
|
|
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).
|
|
229
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
|
+
}
|
|
230
356
|
}
|
|
231
357
|
else {
|
|
232
358
|
const url = new URL(opts.dsn);
|
|
@@ -234,13 +360,78 @@ export class Reprova {
|
|
|
234
360
|
const endpoint = `${url.protocol}//${url.host}/v1/ingest`;
|
|
235
361
|
this.transport = new BatchTransport({ endpoint, apiKey });
|
|
236
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();
|
|
237
402
|
}
|
|
238
403
|
this.release = opts.release;
|
|
239
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();
|
|
240
427
|
}
|
|
241
428
|
static init(opts) {
|
|
242
429
|
instance = new Reprova(opts);
|
|
243
|
-
|
|
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) {
|
|
244
435
|
instance.patchOutbound();
|
|
245
436
|
if (opts.instrumentHttp !== false)
|
|
246
437
|
instrumentHttpServers();
|
|
@@ -251,6 +442,25 @@ export class Reprova {
|
|
|
251
442
|
}
|
|
252
443
|
static getInstance() { return instance; }
|
|
253
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
|
+
}
|
|
254
464
|
// One-call Prisma integration. Splices the footprint-recording extension
|
|
255
465
|
// onto an existing PrismaClient (in place, preserving its identity) and
|
|
256
466
|
// best-effort reads the latest applied migration into migration_id.
|
|
@@ -263,35 +473,167 @@ export class Reprova {
|
|
|
263
473
|
// $extends returns a NEW client and the shared singleton must keep
|
|
264
474
|
// recording. Await it so migration_id is set before the first request.
|
|
265
475
|
async instrumentPrisma(client, opts) {
|
|
266
|
-
|
|
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)
|
|
267
483
|
return;
|
|
268
484
|
const c = client;
|
|
269
|
-
// 1. Footprint capture
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|
|
273
514
|
// 2. migration_id: best-effort. Absent table (migrations not run) or any
|
|
274
515
|
// query error leaves it at the 'unknown' default — never fatal at boot.
|
|
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.
|
|
275
527
|
if (opts?.readMigrationId !== false) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
528
|
+
const rawQuery = c.$queryRaw ?? c.raw;
|
|
529
|
+
if (rawQuery) {
|
|
530
|
+
let rows = [];
|
|
531
|
+
try {
|
|
532
|
+
rows = await rawQuery.bind(c) `
|
|
533
|
+
SELECT migration_name FROM _prisma_migrations ORDER BY finished_at DESC LIMIT 1
|
|
534
|
+
`;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
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
|
+
}
|
|
280
554
|
if (rows[0]?.migration_name) {
|
|
281
555
|
this.setMigrationId(rows[0].migration_name);
|
|
282
556
|
}
|
|
283
557
|
}
|
|
284
|
-
catch {
|
|
285
|
-
// migration_id stays 'unknown'
|
|
286
|
-
}
|
|
287
558
|
}
|
|
288
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
|
+
}
|
|
289
628
|
// One-call Express integration (Sentry-style): mounts the request context
|
|
290
629
|
// middleware (repositioned to run before everything, wherever setup is
|
|
291
630
|
// called), the error-capture middleware (kept last even if routes are
|
|
292
631
|
// registered later), the http_5xx response hook, and async rejection
|
|
293
632
|
// forwarding. Equivalent to the manual requestHandler/errorHandler/
|
|
294
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.
|
|
295
637
|
setupExpress(app) {
|
|
296
638
|
app.use(this.requestHandler());
|
|
297
639
|
const router = app._router;
|
|
@@ -329,7 +671,7 @@ export class Reprova {
|
|
|
329
671
|
keepErrorHandlerLast(req.app);
|
|
330
672
|
}
|
|
331
673
|
// http_5xx-without-throw hook rides along automatically.
|
|
332
|
-
if (!this.disabled)
|
|
674
|
+
if (!this.disabled || this.replayMode)
|
|
333
675
|
wrapResponse(res, this);
|
|
334
676
|
// With HTTP-server instrumentation active (init default), a context
|
|
335
677
|
// for this very request already exists — don't shadow it with a
|
|
@@ -348,25 +690,33 @@ export class Reprova {
|
|
|
348
690
|
path: req.path,
|
|
349
691
|
headers: captureHeaders(req.headers),
|
|
350
692
|
body: typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? ''),
|
|
351
|
-
authClaims: req
|
|
693
|
+
authClaims: detectAuthClaims(req, this.authClaimsExtractor),
|
|
352
694
|
},
|
|
353
695
|
footprint: [],
|
|
354
696
|
outboundCalls: [],
|
|
697
|
+
businessInvariantHits: [],
|
|
698
|
+
writeCountDriftHits: [],
|
|
355
699
|
rawRequest: req,
|
|
356
700
|
};
|
|
357
701
|
// Track this context so a concurrent request's capture can see it
|
|
358
702
|
// (and, briefly after completion, that it recently overlapped).
|
|
359
703
|
registerContext(ctx);
|
|
704
|
+
ctxByRequest.set(req, ctx);
|
|
360
705
|
const done = () => completeContext(ctx);
|
|
361
706
|
res.on('finish', done);
|
|
362
707
|
res.on('close', done);
|
|
363
708
|
contextStorage.run(ctx, next);
|
|
364
709
|
};
|
|
365
710
|
}
|
|
366
|
-
// 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".
|
|
367
717
|
errorHandler() {
|
|
368
718
|
return (err, req, res, next) => {
|
|
369
|
-
const ctx = contextStorage.getStore();
|
|
719
|
+
const ctx = contextStorage.getStore() ?? ctxByRequest.get(req);
|
|
370
720
|
if (ctx && err instanceof Error) {
|
|
371
721
|
this.capture('unhandled_exception', err, ctx);
|
|
372
722
|
}
|
|
@@ -378,6 +728,62 @@ export class Reprova {
|
|
|
378
728
|
const ctx = contextStorage.getStore();
|
|
379
729
|
this.capture(opts?.trigger ?? 'manual_capture', err, ctx);
|
|
380
730
|
}
|
|
731
|
+
// NestJS global exception filter: hands Nest-caught exceptions to the
|
|
732
|
+
// capture pipeline with the ORIGINAL stack (Nest's exception layer
|
|
733
|
+
// otherwise converts them to 500s before Express error middleware ever
|
|
734
|
+
// sees them, leaving only the silent-5xx capture), honors the
|
|
735
|
+
// REPROVA_REPLAY contract for exact replay verdicts, and responds the way
|
|
736
|
+
// Nest's own default filter would. Usage:
|
|
737
|
+
// app.useGlobalFilters(sdk.nestExceptionFilter());
|
|
738
|
+
nestExceptionFilter() {
|
|
739
|
+
return createNestExceptionFilter({
|
|
740
|
+
capture: (err) => {
|
|
741
|
+
const ctx = contextStorage.getStore();
|
|
742
|
+
if (!ctx?.captured)
|
|
743
|
+
this.capture('unhandled_exception', err, ctx);
|
|
744
|
+
},
|
|
745
|
+
});
|
|
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
|
+
}
|
|
763
|
+
// One-call NestJS setup, the setupExpress of the Nest world: registers the
|
|
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:
|
|
767
|
+
// const sdk = Reprova.init({ dsn, release });
|
|
768
|
+
// const app = await NestFactory.create(AppModule);
|
|
769
|
+
// await sdk.setupNest(app, { prisma: app.get(PrismaService), dmmf: Prisma.dmmf });
|
|
770
|
+
// Other ORMs stay their usual one line (installTypeOrmSubscriber(dataSource),
|
|
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).
|
|
780
|
+
async setupNest(app, opts) {
|
|
781
|
+
app.useGlobalFilters(this.nestExceptionFilter());
|
|
782
|
+
app.useGlobalInterceptors(this.nestContextInterceptor());
|
|
783
|
+
if (opts?.prisma) {
|
|
784
|
+
await this.instrumentPrisma(opts.prisma, { dmmf: opts.dmmf });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
381
787
|
// Called by response hook for http_5xx (no thrown error).
|
|
382
788
|
captureHttp5xx(err, statusCode, responseBody) {
|
|
383
789
|
const ctx = contextStorage.getStore();
|
|
@@ -393,12 +799,169 @@ export class Reprova {
|
|
|
393
799
|
response: { status: statusCode, body: responseBody.slice(0, 16 * 1024) },
|
|
394
800
|
});
|
|
395
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
|
+
}
|
|
396
941
|
capture(trigger, err, ctx, extra) {
|
|
397
|
-
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
|
+
}
|
|
398
960
|
return;
|
|
961
|
+
}
|
|
399
962
|
if (ctx) {
|
|
400
963
|
ctx.captured = true;
|
|
401
|
-
refreshRequestSnapshot(ctx);
|
|
964
|
+
refreshRequestSnapshot(ctx, this.authClaimsExtractor);
|
|
402
965
|
}
|
|
403
966
|
const payload = {
|
|
404
967
|
trigger: trigger,
|
|
@@ -408,7 +971,11 @@ export class Reprova {
|
|
|
408
971
|
migration_id: this.migrationId,
|
|
409
972
|
sdk_version: this.sdkVersion,
|
|
410
973
|
error: {
|
|
411
|
-
|
|
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'),
|
|
412
979
|
message: err.message,
|
|
413
980
|
stack: parseStack(err),
|
|
414
981
|
},
|
|
@@ -478,6 +1045,8 @@ export class Reprova {
|
|
|
478
1045
|
},
|
|
479
1046
|
footprint: [],
|
|
480
1047
|
outboundCalls: [],
|
|
1048
|
+
businessInvariantHits: [],
|
|
1049
|
+
writeCountDriftHits: [],
|
|
481
1050
|
};
|
|
482
1051
|
registerContext(ctx);
|
|
483
1052
|
try {
|
|
@@ -495,10 +1064,24 @@ export class Reprova {
|
|
|
495
1064
|
// Record a data-footprint entry from ANY data layer (knex, raw SQL, an
|
|
496
1065
|
// in-house DAO): one call per query site is what makes an error
|
|
497
1066
|
// REPRODUCIBLE rather than just visible. `model` maps to a table via the
|
|
498
|
-
// tenant's naming strategy, exactly like a Prisma model name.
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
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);
|
|
502
1085
|
}
|
|
503
1086
|
patchOutbound() {
|
|
504
1087
|
const sdk = this;
|
|
@@ -572,7 +1155,21 @@ export class Reprova {
|
|
|
572
1155
|
};
|
|
573
1156
|
}
|
|
574
1157
|
}
|
|
575
|
-
|
|
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();
|
|
576
1173
|
this.transport.stop();
|
|
577
1174
|
await this.transport.flush();
|
|
578
1175
|
}
|
|
@@ -587,6 +1184,38 @@ export function wrapResponse(res, sdk) {
|
|
|
587
1184
|
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
|
588
1185
|
sdk.captureHttp5xx(null, res.statusCode, bodyStr.slice(0, 16 * 1024));
|
|
589
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
|
+
}
|
|
590
1219
|
return originalJson(body);
|
|
591
1220
|
};
|
|
592
1221
|
}
|