@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/README.md
CHANGED
|
@@ -55,7 +55,7 @@ anything else records them with one `recordFootprint` call per query site.
|
|
|
55
55
|
| Sequelize | `installSequelizeHooks` | ✅ exact (model metadata) | ✅ |
|
|
56
56
|
| TypeORM | `installTypeOrmSubscriber` | ✅ exact (entity metadata) | ➖ |
|
|
57
57
|
| Knex / Objection | `installKnexHooks` | ✅ SELECT; INSERT depends on driver RETURNING | ➖ |
|
|
58
|
-
| Drizzle | `
|
|
58
|
+
| Drizzle | `createDrizzleFootprint` | ✅ SELECT (via Cache hook); ❌ writes (Drizzle exposes no result hook for them) | ✅ |
|
|
59
59
|
|
|
60
60
|
## Express (optional enrichment)
|
|
61
61
|
|
|
@@ -64,6 +64,70 @@ const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha });
|
|
|
64
64
|
sdk.setupExpress(app); // async-rejection forwarding + error-middleware capture + parsed bodies
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
## Auth claims (automatic for most apps, zero code changes)
|
|
68
|
+
|
|
69
|
+
`repro run`/`repro test` re-sign the captured request's auth claims into a
|
|
70
|
+
token the replayed app can validate — but only if the SDK captured claims in
|
|
71
|
+
the first place. Auto-detection covers the two dominant conventions, no
|
|
72
|
+
wiring needed:
|
|
73
|
+
|
|
74
|
+
- `req.auth` — `express-oauth2-jwt-bearer` / Auth0-style middleware
|
|
75
|
+
- `req.user` — Passport, and the overwhelming majority of hand-rolled
|
|
76
|
+
Express JWT middleware (`req.user = decoded` right after `jwt.verify`)
|
|
77
|
+
|
|
78
|
+
Only apps using neither — a custom property, a non-Express framework — need
|
|
79
|
+
one explicit call from inside their own auth middleware:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { setAuthClaims } from '@reprova/sdk';
|
|
83
|
+
|
|
84
|
+
function verifyToken(req, res, next) {
|
|
85
|
+
const decoded = jwt.verify(token, secret);
|
|
86
|
+
req.identity = decoded; // some other property auto-detection doesn't check
|
|
87
|
+
setAuthClaims(decoded); // tells Reprova what to re-sign at replay time
|
|
88
|
+
next();
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Without it, replay has no claims to re-sign, sends no `Authorization`
|
|
93
|
+
header, and the app rejects the replayed request as unauthenticated before
|
|
94
|
+
it ever reaches the captured bug — `repro run` reports `DIFFERENT FAILURE`
|
|
95
|
+
instead of reproducing the original error. `setAuthClaims` is a no-op
|
|
96
|
+
outside a Reprova request context, so it's safe to call unconditionally.
|
|
97
|
+
|
|
98
|
+
## Framework adapters (auth claims + migration_id for anything else)
|
|
99
|
+
|
|
100
|
+
`setAuthClaims` (above) and `sdk.setMigrationId` cover apps that can call
|
|
101
|
+
something from inside their own request/startup code. Some frameworks
|
|
102
|
+
resolve both through their own machinery instead — a DI container, a
|
|
103
|
+
non-Express request shape — with no single call site to hook. One call
|
|
104
|
+
covers both at once:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha });
|
|
108
|
+
|
|
109
|
+
sdk.registerFrameworkAdapter({
|
|
110
|
+
// Return undefined (not {}) to fall back to the built-in req.auth/req.user
|
|
111
|
+
// check instead of claiming "no auth" — useful if your framework only
|
|
112
|
+
// sometimes populates its own property.
|
|
113
|
+
extractAuthClaims: (rawRequest) => {
|
|
114
|
+
const req = rawRequest as { auth_context?: Record<string, unknown> };
|
|
115
|
+
return req.auth_context; // e.g. Medusa's own auth context
|
|
116
|
+
},
|
|
117
|
+
// Called once at registration — re-register (or call sdk.setMigrationId
|
|
118
|
+
// directly) if it can change during the process's lifetime.
|
|
119
|
+
resolveMigrationId: async () => {
|
|
120
|
+
const row = await db.query('SELECT name FROM my_migrations_table ORDER BY applied_at DESC LIMIT 1');
|
|
121
|
+
return row?.name ?? 'unknown';
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Both fields are optional independently — supply only the one your framework
|
|
127
|
+
needs. Neither ever throws into request handling: `extractAuthClaims`
|
|
128
|
+
falls back to `req.auth ?? req.user` on error or `undefined`, and a
|
|
129
|
+
rejected `resolveMigrationId` just leaves `migration_id` as `'unknown'`.
|
|
130
|
+
|
|
67
131
|
## Prisma (optional enrichment — automatic footprints)
|
|
68
132
|
|
|
69
133
|
One call wires both automatic footprints and `migration_id`:
|
|
@@ -161,18 +225,116 @@ affected-row count; INSERT PK fidelity depends on the driver's RETURNING
|
|
|
161
225
|
support (full on Postgres, last-id only on sqlite/mysql). No `where_shape`
|
|
162
226
|
(Knex has no query AST).
|
|
163
227
|
|
|
164
|
-
## Drizzle (optional enrichment — shape-only)
|
|
228
|
+
## Drizzle (optional enrichment — exact PKs for SELECT, shape-only writes)
|
|
165
229
|
|
|
166
|
-
Drizzle has no result-aware hook
|
|
167
|
-
|
|
230
|
+
Drizzle has no single result-aware hook for every operation, but it has two
|
|
231
|
+
partial ones that together cover most of it: a `logger` (SQL text, no rows)
|
|
232
|
+
and a `Cache` (real result rows for SELECT, no rows for writes).
|
|
233
|
+
`createDrizzleFootprint()` wires both, merged into one footprint entry per
|
|
234
|
+
query — no double-counting:
|
|
168
235
|
|
|
169
236
|
```ts
|
|
170
|
-
import {
|
|
171
|
-
const
|
|
237
|
+
import { createDrizzleFootprint } from '@reprova/sdk';
|
|
238
|
+
const { logger, cache } = createDrizzleFootprint();
|
|
239
|
+
const db = drizzle(client, { logger, cache });
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
- **SELECT**: exact PKs and row count, extracted from the real rows Drizzle's
|
|
243
|
+
`Cache.put()` hook receives — `get()` always returns a miss, so this never
|
|
244
|
+
serves a cached (possibly stale) result back to the app, it only observes.
|
|
245
|
+
PK columns default to `['id']`; override for composite or non-`id` keys the
|
|
246
|
+
same way as Kysely: `createDrizzleFootprint({ primaryKey: (table) => [...] })`.
|
|
247
|
+
- **INSERT/UPDATE/DELETE**: shape-only (table + operation + WHERE column
|
|
248
|
+
names), same as before — Drizzle's mutation hook (`onMutate`) gets table
|
|
249
|
+
names only, never rows or even a count, and it races the real query
|
|
250
|
+
internally. For PK-level fidelity on writes, still call
|
|
251
|
+
`sdk.recordFootprint(...)` at the query sites that matter.
|
|
252
|
+
- Only works for **async drivers** (`node-postgres`, `postgres-js`, `mysql2`,
|
|
253
|
+
etc. — Reprova's two supported dialects for the customer's own database).
|
|
254
|
+
Sync drivers (`better-sqlite3`, `bun:sqlite`) bypass Drizzle's cache path
|
|
255
|
+
entirely; use `createDrizzleLogger()` alone there (shape-only, as before).
|
|
256
|
+
Drizzle ships no SQL Server driver at all, so this adapter never covers
|
|
257
|
+
that lane regardless of driver.
|
|
258
|
+
|
|
259
|
+
Prefer to build the pair yourself, or use just one half?
|
|
260
|
+
`createDrizzleLogger()` and `createDrizzleCache({ logger })` are still
|
|
261
|
+
exported individually — pass the SAME logger instance to `createDrizzleCache`
|
|
262
|
+
to get the merge behavior; omit it to run the cache standalone (exact
|
|
263
|
+
SELECT PKs, no `where_shape`, no write coverage).
|
|
264
|
+
|
|
265
|
+
## N+1 query detection (automatic once any ORM adapter above is wired up)
|
|
266
|
+
|
|
267
|
+
Every adapter above feeds the same per-request footprint (one entry per
|
|
268
|
+
query call). The SDK watches that footprint on every response — success or
|
|
269
|
+
failure — for the same `(model, operation)` shape executed repeatedly as
|
|
270
|
+
separate calls in one request: a `for` loop firing one `findUnique` per
|
|
271
|
+
item instead of a single batched query. No exception, no 5xx — the request
|
|
272
|
+
usually returns 200 while doing far more DB round-trips than it should,
|
|
273
|
+
which is exactly the case nothing else here catches.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha, n1Threshold: 5 });
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`n1Threshold` (default `5`) is how many times the same shape has to repeat
|
|
280
|
+
in one request before it's flagged; `0` disables detection entirely. It
|
|
281
|
+
can't mistake one efficient query for N+1: an `include`/join produces
|
|
282
|
+
exactly one footprint entry per relation regardless of how many rows it
|
|
283
|
+
returns, so only genuinely separate calls count.
|
|
284
|
+
|
|
285
|
+
## Retry storm detection (automatic — no wiring needed)
|
|
286
|
+
|
|
287
|
+
Every outbound `fetch()` call is already recorded per request (host,
|
|
288
|
+
method, path, status, outcome — used for outbound-call replay). The SDK
|
|
289
|
+
watches those calls, on every response, for the same `(host, method, path)`
|
|
290
|
+
called repeatedly with at least one failure among the attempts — a naive
|
|
291
|
+
retry loop hammering an already-struggling dependency instead of backing
|
|
292
|
+
off. Repetition alone isn't enough to flag: calling the same endpoint
|
|
293
|
+
several times and getting `200` every time is normal pagination or batch
|
|
294
|
+
fetching, not a storm — only repeats that include a failure (a `5xx`, or a
|
|
295
|
+
network error/timeout) count.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha, retryStormThreshold: 3 });
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
`retryStormThreshold` (default `3`, lower than `n1Threshold`'s `5` since
|
|
302
|
+
real backoff configs commonly cap around 3 attempts) is how many calls to
|
|
303
|
+
the same target it takes before a repeat-with-failures is flagged; `0`
|
|
304
|
+
disables it. The query string is stripped before grouping, since a real
|
|
305
|
+
retry commonly varies a request id or cache-busting param per attempt.
|
|
306
|
+
|
|
307
|
+
## NestJS (optional enrichment — original stacks + replay parity)
|
|
308
|
+
|
|
309
|
+
Nest's exception layer catches thrown errors before Express error middleware
|
|
310
|
+
ever sees them and converts them to 500s — so with only `init()`, Nest errors
|
|
311
|
+
are captured via the silent-5xx hook: full request context and footprint, but
|
|
312
|
+
a synthesized error instead of the real one. The filter closes that gap, and
|
|
313
|
+
gives Nest apps the same `REPROVA_REPLAY` exact-stack contract as
|
|
314
|
+
`replayErrorHandler`:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha });
|
|
318
|
+
const app = await NestFactory.create(AppModule);
|
|
319
|
+
await sdk.setupNest(app, { prisma: app.get(PrismaService), dmmf: Prisma.dmmf });
|
|
172
320
|
```
|
|
173
321
|
|
|
174
|
-
|
|
175
|
-
|
|
322
|
+
`setupNest` registers the exception filter and (when given a Prisma client)
|
|
323
|
+
wires automatic footprints + `migration_id` — the whole integration in one
|
|
324
|
+
call, like `setupExpress`. Using TypeORM/Knex/etc. instead? Call
|
|
325
|
+
`setupNest(app)` bare and add the usual one-liner
|
|
326
|
+
(`installTypeOrmSubscriber(dataSource)`, ...). Prefer manual control?
|
|
327
|
+
`app.useGlobalFilters(sdk.nestExceptionFilter())` is still exported.
|
|
328
|
+
|
|
329
|
+
No decorator needed — a filter without `@Catch` metadata is catch-all, and
|
|
330
|
+
the SDK never imports `@nestjs/*` (structural typing, like every adapter
|
|
331
|
+
here; proven against real Nest in `test/nest.integration.test.ts`). Behavior
|
|
332
|
+
mirrors Nest's own default filter: intentional `HttpException`s (4xx) pass
|
|
333
|
+
through untouched and uncaptured; non-HTTP exceptions and 5xx
|
|
334
|
+
`HttpException`s are captured with their original type/message/stack and
|
|
335
|
+
answered with the default body. Works on the Express and Fastify adapters.
|
|
336
|
+
If you register other filters, list this one first in `useGlobalFilters` so
|
|
337
|
+
more specific `@Catch(...)` filters keep winning.
|
|
176
338
|
|
|
177
339
|
## Express (continued)
|
|
178
340
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.contextStorage = void 0;
|
|
4
|
+
exports.currentContext = currentContext;
|
|
5
|
+
exports.setAuthClaims = setAuthClaims;
|
|
6
|
+
const async_hooks_1 = require("async_hooks");
|
|
7
|
+
exports.contextStorage = new async_hooks_1.AsyncLocalStorage();
|
|
8
|
+
function currentContext() {
|
|
9
|
+
return exports.contextStorage.getStore();
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Explicitly records the authenticated identity for the in-flight request.
|
|
13
|
+
* Auto-detection already covers `req.auth` (express-oauth2-jwt-bearer/Auth0)
|
|
14
|
+
* and `req.user` (Passport and most hand-rolled Express JWT middleware) —
|
|
15
|
+
* see detectAuthClaims in sdk.ts — so most apps need zero code changes. This
|
|
16
|
+
* is the escape hatch for anything else: a custom property, a non-Express
|
|
17
|
+
* framework. Call it from inside your own auth middleware once the token is
|
|
18
|
+
* verified, e.g. `setAuthClaims(decoded)` right after `jwt.verify`. Without
|
|
19
|
+
* captured claims one way or the other, replay has nothing to re-sign into a
|
|
20
|
+
* token and the app bounces the replayed request as unauthenticated before
|
|
21
|
+
* it ever reaches the captured bug. No-ops outside a Reprova request
|
|
22
|
+
* context.
|
|
23
|
+
*/
|
|
24
|
+
function setAuthClaims(claims) {
|
|
25
|
+
const ctx = currentContext();
|
|
26
|
+
if (ctx)
|
|
27
|
+
ctx.request.authClaims = claims;
|
|
28
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DistributionBaselinePoller = void 0;
|
|
4
|
+
const httpFetch_js_1 = require("./httpFetch.js");
|
|
5
|
+
const WARN_INTERVAL_MS = 60_000;
|
|
6
|
+
// Same start()/stop()/setInterval+unref shape as RulesPoller and
|
|
7
|
+
// IdempotencySettingsPoller — periodically fetches the tenant's current
|
|
8
|
+
// aggregate baselines (and the drift-detection on/off toggle) so a running
|
|
9
|
+
// process can check each response against a live baseline without a
|
|
10
|
+
// redeploy, and so drift detection stays off by default until an admin
|
|
11
|
+
// opts in from the dashboard. Falls back to disabled + no baselines before
|
|
12
|
+
// the first successful poll and on any failure, so a bad response or an
|
|
13
|
+
// unreachable control plane fails closed (no captures), not open (spurious
|
|
14
|
+
// captures from a stale or empty baseline).
|
|
15
|
+
class DistributionBaselinePoller {
|
|
16
|
+
enabled = false;
|
|
17
|
+
baselines = new Map();
|
|
18
|
+
timer = null;
|
|
19
|
+
lastWarnAt = 0;
|
|
20
|
+
endpoint;
|
|
21
|
+
apiKey;
|
|
22
|
+
intervalMs;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.endpoint = opts.endpoint;
|
|
25
|
+
this.apiKey = opts.apiKey;
|
|
26
|
+
this.intervalMs = opts.intervalMs ?? 60_000;
|
|
27
|
+
}
|
|
28
|
+
start() {
|
|
29
|
+
void this.refresh();
|
|
30
|
+
this.timer = setInterval(() => void this.refresh(), this.intervalMs);
|
|
31
|
+
if (this.timer.unref)
|
|
32
|
+
this.timer.unref();
|
|
33
|
+
}
|
|
34
|
+
stop() {
|
|
35
|
+
if (this.timer) {
|
|
36
|
+
clearInterval(this.timer);
|
|
37
|
+
this.timer = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
isEnabled() {
|
|
41
|
+
return this.enabled;
|
|
42
|
+
}
|
|
43
|
+
getBaseline(route, field) {
|
|
44
|
+
return this.baselines.get(`${route}\x00${field}`);
|
|
45
|
+
}
|
|
46
|
+
async refresh() {
|
|
47
|
+
try {
|
|
48
|
+
const res = await (0, httpFetch_js_1.httpFetch)(this.endpoint, {
|
|
49
|
+
headers: { 'X-Reprova-Key': this.apiKey },
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
this.warnOnce(`reprova: distribution baselines fetch returned ${res.status}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const body = (await res.json());
|
|
56
|
+
this.applyResponse(body);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
this.warnOnce(`reprova: distribution baselines fetch error: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Applies an already-fetched response directly, with no network call —
|
|
63
|
+
// used by replay mode to seed a one-time snapshot the CLI fetched ahead of
|
|
64
|
+
// time (there's no live control plane to poll from inside a replayed
|
|
65
|
+
// app), so drift detection (checkDistributionDriftIfAny/
|
|
66
|
+
// captureWriteCountDriftIfAny in sdk.ts) sees exactly the same shape it
|
|
67
|
+
// would from a real refresh().
|
|
68
|
+
seed(body) {
|
|
69
|
+
this.applyResponse(body);
|
|
70
|
+
}
|
|
71
|
+
applyResponse(body) {
|
|
72
|
+
this.enabled = body.enabled ?? false;
|
|
73
|
+
const next = new Map();
|
|
74
|
+
for (const b of body.baselines ?? []) {
|
|
75
|
+
next.set(`${b.route}\x00${b.field}`, { sampleCount: b.sample_count, mean: b.mean, stddev: b.stddev });
|
|
76
|
+
}
|
|
77
|
+
this.baselines = next;
|
|
78
|
+
}
|
|
79
|
+
warnOnce(msg) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (now - this.lastWarnAt > WARN_INTERVAL_MS) {
|
|
82
|
+
this.lastWarnAt = now;
|
|
83
|
+
console.warn(msg);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
exports.DistributionBaselinePoller = DistributionBaselinePoller;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Distributional drift, numeric-only v1 slice: baseline the typical range
|
|
3
|
+
// of a numeric response field per (route, field) using Welford's online
|
|
4
|
+
// mean/variance algorithm, and flag a value that's implausibly far from
|
|
5
|
+
// that baseline — a `total` field that's normally 10-500 suddenly
|
|
6
|
+
// returning -40, with no rule authored and no equality asserted anywhere.
|
|
7
|
+
// Unlike business-invariant-rules and idempotency-key matching, this is a
|
|
8
|
+
// statistical guess with real false-positive risk (there's no
|
|
9
|
+
// "asymptotically safe" case the way an app-asserted equality is), so it's
|
|
10
|
+
// off by default and the thresholds below are fixed, not yet
|
|
11
|
+
// tenant-configurable.
|
|
12
|
+
//
|
|
13
|
+
// Deliberately scoped to numeric fields only for this slice — categorical
|
|
14
|
+
// novelty (a status enum emitting a never-seen value) is a structurally
|
|
15
|
+
// different mechanism (tracking a bounded set of distinct values, not a
|
|
16
|
+
// running mean/variance) and is left for a later slice.
|
|
17
|
+
//
|
|
18
|
+
// Observes the top-level fields of a JSON object response body only — not
|
|
19
|
+
// nested objects/arrays, and not array-shaped top-level responses (a list
|
|
20
|
+
// endpoint's items each have their own values; there's no single baseline
|
|
21
|
+
// for "the array"). That keeps the walk trivial and the key space bounded
|
|
22
|
+
// to (route, field) instead of an unbounded set of JSON paths.
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.DistributionDriftReporter = exports.WriteCountDrift = exports.ValueDistributionDrift = void 0;
|
|
25
|
+
exports.newWelfordState = newWelfordState;
|
|
26
|
+
exports.updateWelfordState = updateWelfordState;
|
|
27
|
+
exports.isDrift = isDrift;
|
|
28
|
+
exports.extractTopLevelNumericFields = extractTopLevelNumericFields;
|
|
29
|
+
const httpFetch_js_1 = require("./httpFetch.js");
|
|
30
|
+
const MIN_SAMPLES = 200;
|
|
31
|
+
const SIGMA_MULTIPLIER = 6;
|
|
32
|
+
class ValueDistributionDrift extends Error {
|
|
33
|
+
}
|
|
34
|
+
exports.ValueDistributionDrift = ValueDistributionDrift;
|
|
35
|
+
// Same mean/variance mechanism, same MIN_SAMPLES/SIGMA_MULTIPLIER thresholds,
|
|
36
|
+
// entirely different observation site: prisma.ts's observeWriteCountDrift
|
|
37
|
+
// baselines a Prisma updateMany/deleteMany/createMany's affected-row count
|
|
38
|
+
// per (route, operation:model) instead of an HTTP response field's value. Shares
|
|
39
|
+
// WelfordState/updateWelfordState/isDrift directly — the math doesn't care
|
|
40
|
+
// what number it's baselining — and DistributionDriftReporter/
|
|
41
|
+
// DistributionBaselinePoller are already fully generic (record/getBaseline
|
|
42
|
+
// take arbitrary string keys), so a second instance of each, pointed at a
|
|
43
|
+
// separate control-plane endpoint, is all that's needed there too. Kept as
|
|
44
|
+
// a distinct Error class/trigger since it's a conceptually separate
|
|
45
|
+
// question ("did this write touch a weird number of rows" vs. "is this
|
|
46
|
+
// response value weird") with its own independent dashboard toggle.
|
|
47
|
+
class WriteCountDrift extends Error {
|
|
48
|
+
}
|
|
49
|
+
exports.WriteCountDrift = WriteCountDrift;
|
|
50
|
+
function newWelfordState() {
|
|
51
|
+
return { count: 0, mean: 0, m2: 0, min: Infinity, max: -Infinity };
|
|
52
|
+
}
|
|
53
|
+
// Standard single-pass Welford update — mean and M2 (sum of squared
|
|
54
|
+
// deviations from the running mean) stay numerically stable across an
|
|
55
|
+
// unbounded stream, unlike naively accumulating sum/sum-of-squares.
|
|
56
|
+
function updateWelfordState(state, x) {
|
|
57
|
+
state.count++;
|
|
58
|
+
const delta = x - state.mean;
|
|
59
|
+
state.mean += delta / state.count;
|
|
60
|
+
const delta2 = x - state.mean;
|
|
61
|
+
state.m2 += delta * delta2;
|
|
62
|
+
if (x < state.min)
|
|
63
|
+
state.min = x;
|
|
64
|
+
if (x > state.max)
|
|
65
|
+
state.max = x;
|
|
66
|
+
}
|
|
67
|
+
// A value is drift when the baseline has enough samples to trust AND the
|
|
68
|
+
// value is more than SIGMA_MULTIPLIER standard deviations from the mean.
|
|
69
|
+
// A zero-variance baseline (every sample identical so far) treats ANY
|
|
70
|
+
// different value as infinite deviation — flagged, since after MIN_SAMPLES
|
|
71
|
+
// consistent observations a field that never varied suddenly doing so is
|
|
72
|
+
// exactly the kind of thing worth surfacing, not a numerical edge case to
|
|
73
|
+
// special-case away.
|
|
74
|
+
function isDrift(value, baseline) {
|
|
75
|
+
if (baseline.sampleCount < MIN_SAMPLES)
|
|
76
|
+
return false;
|
|
77
|
+
const deviation = baseline.stddev === 0 ? (value !== baseline.mean ? Infinity : 0) : Math.abs(value - baseline.mean) / baseline.stddev;
|
|
78
|
+
return deviation > SIGMA_MULTIPLIER;
|
|
79
|
+
}
|
|
80
|
+
// Extracts finite numeric values from the response body's OWN top-level
|
|
81
|
+
// keys — null/NaN/Infinity are skipped (not a "value", nothing to
|
|
82
|
+
// baseline), and a non-object or array body yields nothing (see module doc
|
|
83
|
+
// for why array-shaped top-level responses are out of scope for this slice).
|
|
84
|
+
function extractTopLevelNumericFields(body) {
|
|
85
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
86
|
+
return {};
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const [k, v] of Object.entries(body)) {
|
|
89
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
90
|
+
out[k] = v;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
const WARN_INTERVAL_MS = 60_000;
|
|
95
|
+
// Accumulates local Welford state per (route, field) since the last flush
|
|
96
|
+
// and periodically reports it as a delta — same start/stop/periodic-flush
|
|
97
|
+
// shape as BatchTransport/InferenceReporter. The control plane merges each
|
|
98
|
+
// report into the tenant's cumulative baseline via the parallel-variance
|
|
99
|
+
// formula; a dropped/failed flush only ever loses that batch's
|
|
100
|
+
// contribution (undercounts), never double-reports on retry, since there
|
|
101
|
+
// is no retry — same trade-off every other reporter in this SDK already
|
|
102
|
+
// makes.
|
|
103
|
+
class DistributionDriftReporter {
|
|
104
|
+
states = new Map();
|
|
105
|
+
timer = null;
|
|
106
|
+
lastWarnAt = 0;
|
|
107
|
+
endpoint;
|
|
108
|
+
apiKey;
|
|
109
|
+
flushIntervalMs;
|
|
110
|
+
constructor(opts) {
|
|
111
|
+
this.endpoint = opts.endpoint;
|
|
112
|
+
this.apiKey = opts.apiKey;
|
|
113
|
+
this.flushIntervalMs = opts.flushIntervalMs ?? 30_000;
|
|
114
|
+
}
|
|
115
|
+
start() {
|
|
116
|
+
this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
|
|
117
|
+
if (this.timer.unref)
|
|
118
|
+
this.timer.unref();
|
|
119
|
+
}
|
|
120
|
+
stop() {
|
|
121
|
+
if (this.timer) {
|
|
122
|
+
clearInterval(this.timer);
|
|
123
|
+
this.timer = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
record(route, field, value) {
|
|
127
|
+
const key = `${route}\x00${field}`;
|
|
128
|
+
let entry = this.states.get(key);
|
|
129
|
+
if (!entry) {
|
|
130
|
+
entry = { route, field, state: newWelfordState() };
|
|
131
|
+
this.states.set(key, entry);
|
|
132
|
+
}
|
|
133
|
+
updateWelfordState(entry.state, value);
|
|
134
|
+
}
|
|
135
|
+
async flush() {
|
|
136
|
+
if (this.states.size === 0)
|
|
137
|
+
return;
|
|
138
|
+
const observations = [...this.states.values()].map(({ route, field, state }) => ({
|
|
139
|
+
route,
|
|
140
|
+
field,
|
|
141
|
+
count: state.count,
|
|
142
|
+
mean: state.mean,
|
|
143
|
+
m2: state.m2,
|
|
144
|
+
min: state.min,
|
|
145
|
+
max: state.max,
|
|
146
|
+
}));
|
|
147
|
+
this.states.clear();
|
|
148
|
+
try {
|
|
149
|
+
const res = await (0, httpFetch_js_1.httpFetch)(this.endpoint, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'Content-Type': 'application/json', 'X-Reprova-Key': this.apiKey },
|
|
152
|
+
body: JSON.stringify({ observations }),
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
this.warnOnce(`reprova: distribution baseline report returned ${res.status}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
this.warnOnce(`reprova: distribution baseline report error: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
warnOnce(msg) {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
if (now - this.lastWarnAt > WARN_INTERVAL_MS) {
|
|
165
|
+
this.lastWarnAt = now;
|
|
166
|
+
console.warn(msg);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
exports.DistributionDriftReporter = DistributionDriftReporter;
|