@reprova/sdk 0.5.0 → 0.6.1
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 +75 -0
- 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 +101 -0
- package/dist/cjs/invariantInference.js +90 -0
- package/dist/cjs/invariantRules.js +142 -0
- package/dist/cjs/knex.js +105 -0
- package/dist/cjs/kysely.js +201 -0
- package/dist/cjs/legacyPrisma.js +130 -0
- package/dist/cjs/mikroorm.js +107 -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 +146 -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 +231 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/drizzle.js +10 -4
- 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 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -6
- 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 +1 -0
- package/dist/knex.d.ts.map +1 -1
- package/dist/knex.js +19 -0
- package/dist/knex.js.map +1 -1
- package/dist/kysely.d.ts +1 -0
- package/dist/kysely.d.ts.map +1 -1
- package/dist/kysely.js +27 -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 +3 -0
- package/dist/mikroorm.d.ts.map +1 -0
- package/dist/mikroorm.js +104 -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 +13 -0
- package/dist/nest.d.ts.map +1 -1
- package/dist/nest.js +39 -0
- package/dist/nest.js.map +1 -1
- 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 +10 -2
- 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 +48 -7
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +619 -47
- package/dist/sdk.js.map +1 -1
- package/dist/sequelize.d.ts +1 -0
- package/dist/sequelize.d.ts.map +1 -1
- package/dist/sequelize.js +41 -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 +2 -0
- package/dist/typeorm.d.ts.map +1 -1
- package/dist/typeorm.js +163 -1
- package/dist/typeorm.js.map +1 -1
- 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 {
|
|
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.
|
|
311
|
+
sdkVersion = '0.6.1';
|
|
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
|
|
242
|
-
// 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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
//
|
|
290
|
-
//
|
|
291
|
-
// has no notion of "which dialect"
|
|
292
|
-
// it
|
|
293
|
-
//
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
rows =
|
|
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
|
|
304
|
-
SELECT
|
|
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
|
-
|
|
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
|
|
426
|
-
// footprints + migration_id in 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:
|
|
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
|
-
|
|
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.
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
}
|