@reprova/sdk
v0.4.0
Published
Reprova SDK for Node.js: Express error capture, Prisma data-footprint recording, and outbound HTTP call recording for locally runnable reproductions
Maintainers
Readme
@reprova/sdk
Node.js/TypeScript SDK for any Node app — vanilla node:http, Koa, Fastify,
Express, background workers. Reprova.init() instruments Node's own HTTP servers,
so every request gets capture context (errors, silent 5xxs, headers, trace ids,
outbound HTTP calls) with zero framework code. Express and Prisma integrations are
optional enrichments: Express adds async-rejection forwarding and parsed request
bodies; Prisma adds automatic data footprints (which tables/rows each request
touched — what makes an error reproducible, not just visible). The batched,
fire-and-forget transport never adds meaningful latency, even when the control
plane is unreachable (transport.test.ts; chaos.spec.ts proves it against the
real running control plane).
Install
npm install @reprova/sdkUsage — any Node app
import { Reprova } from '@reprova/sdk';
Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha });
// That's it. A plain http.createServer / Koa / Fastify app now captures
// errors, silent 5xx responses, request context, trace ids, outbound calls.init instruments http.Server/https.Server directly and attaches
process-level error handlers (opt-outs: instrumentHttp: false,
processHandlers: false; uncaughtException flushes then exits 1). A missing
dsn puts the SDK in disabled mode: everything mounts, nothing records.
Background jobs and scripts
await sdk.runJob('nightly-sync', async () => { ... }); // failures ingest as job_failure
await sdk.runWithContext({ name: 'csv-import' }, doImport); // generic wrapper: capture + rethrowData footprints without Prisma (knex, raw SQL, any DAO)
const rows = await knex('invoices').where({ id }).select();
sdk.recordFootprint({ model: 'Invoice', op: 'select', pks: rows.map(r => r.id), count: rows.length });Reproductions need footprints. Six ORMs record them automatically (below);
anything else records them with one recordFootprint call per query site.
| ORM | Helper | Auto PKs | where_shape |
| --- | --- | --- | --- |
| Prisma | instrumentPrisma / createPrismaExtension | ✅ exact (DMMF) | ✅ |
| Kysely | createKyselyPlugin | ✅ (primaryKey opt for composite) | ✅ |
| Sequelize | installSequelizeHooks | ✅ exact (model metadata) | ✅ |
| TypeORM | installTypeOrmSubscriber | ✅ exact (entity metadata) | ➖ |
| Knex / Objection | installKnexHooks | ✅ SELECT; INSERT depends on driver RETURNING | ➖ |
| Drizzle | createDrizzleLogger | ❌ shape-only (no result hook) | ✅ |
Express (optional enrichment)
const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha });
sdk.setupExpress(app); // async-rejection forwarding + error-middleware capture + parsed bodiesPrisma (optional enrichment — automatic footprints)
One call wires both automatic footprints and migration_id:
const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha });
await sdk.instrumentPrisma(prisma, { dmmf: Prisma.dmmf });instrumentPrisma splices the footprint extension onto your existing client
in place (so the shared singleton keeps recording) and best-effort reads the
latest row of _prisma_migrations into migration_id. It's a no-op when the
SDK is disabled (no dsn / replay), so no if (dsn) guard is needed, and it
never imports @prisma/client — Prisma stays an optional peer. A missing or
unreadable migrations table is non-fatal: migration_id stays 'unknown'.
Pass { readMigrationId: false } to set it yourself via sdk.setMigrationId.
Prefer to own the wiring? The extension is still exported directly:
import { createPrismaExtension } from '@reprova/sdk';
const prisma = new PrismaClient().$extends(createPrismaExtension(Prisma.dmmf));Kysely (optional enrichment — automatic footprints)
Add the plugin at construction; every query then records a footprint:
import { createKyselyPlugin } from '@reprova/sdk';
const db = new Kysely<DB>({ dialect, plugins: [createKyselyPlugin()] });The plugin reads the compiled AST for the table(s) and operation, the WHERE
clause's referenced column names (never values — PII-safe by
construction), and the returned rows for primary keys. Joined/subquery tables
are recorded as referenced (without PKs). It's a no-op outside a captured
request, never imports kysely, and can never break a query (every path is
guarded).
Kysely carries no schema metadata at runtime, so PK detection defaults to
['id']. Override for composite or non-id keys:
createKyselyPlugin({ primaryKey: (table) => table === 'membership' ? ['tenantId', 'userId'] : ['id'] });migration_id is independent of the ORM: read your migration tool's version
table (Kysely's default is kysely_migration) and call
sdk.setMigrationId(latest), or leave it 'unknown'.
Sequelize (optional enrichment — automatic footprints)
Install once after your models are defined; every query records a footprint:
import { installSequelizeHooks } from '@reprova/sdk';
installSequelizeHooks(sequelize);Uses Sequelize's lifecycle hooks (afterFind, afterCreate, …) plus each
model's tableName / primaryKeyAttributes, so PKs are exact. Hooks are
registered per model (and on afterDefine for later ones), so the table is
known even when a query returns 0 rows. where_shape = the WHERE object's
top-level keys (names only).
TypeORM (optional enrichment — automatic footprints)
Attach the subscriber after dataSource.initialize():
import { installTypeOrmSubscriber } from '@reprova/sdk';
await dataSource.initialize();
installTypeOrmSubscriber(dataSource);Uses entity lifecycle events (afterLoad, afterInsert, …) and each entity's
metadata for exact PKs. TypeORM fires these per entity with no WHERE context,
so entries are merged by (table, op) within a request (union of distinct
PKs), and there is no where_shape.
Knex / Objection (optional enrichment — automatic footprints)
One listener covers raw Knex and Objection.js (which runs through the same Knex instance):
import { installKnexHooks } from '@reprova/sdk';
installKnexHooks(knex);Reads the table and operation from Knex's query-response event. SELECT rows
yield PKs (via primaryKey(table), default ['id']); UPDATE/DELETE record an
affected-row count; INSERT PK fidelity depends on the driver's RETURNING
support (full on Postgres, last-id only on sqlite/mysql). No where_shape
(Knex has no query AST).
Drizzle (optional enrichment — shape-only)
Drizzle has no result-aware hook, only a logger — so this adapter records the table, operation, and WHERE column names, but cannot extract PKs:
import { createDrizzleLogger } from '@reprova/sdk';
const db = drizzle(client, { logger: createDrizzleLogger() });For PK-level fidelity with Drizzle, add sdk.recordFootprint(...) at the query
sites that matter; the logger gives cheap table-level coverage on top.
Express (continued)
setupExpress(app) can be called before or after your routes; it wires the
request-context middleware (repositioned to run first), error capture, the
HTTP-5xx hook, and async rejection forwarding in one call. A missing dsn
puts the SDK in disabled mode: nothing is recorded or sent, but Express-5
rejection semantics still apply — so apps can call init unconditionally.
Prefer manual control? sdk.requestHandler() / sdk.errorHandler() /
wrapResponse are still exported and behave exactly as before:
app.use(sdk.requestHandler());
// ... your routes ...
app.use(sdk.errorHandler());Every capture carries a W3C trace_id, the git release sha, and the latest applied
Prisma migration_id — non-negotiable schema fields the rest of the pipeline depends on.
Async handlers just work
Existing route code needs no changes — no asyncHandler wrapper, no
try/catch → next(err):
app.get('/invoices/:id', async (req, res) => {
const invoice = await db.invoice.find(req.params.id); // a rejection here is
res.json(invoice); // captured automatically
});Express 4 normally discards the promise an async handler returns, so a rejection never reaches error middleware. On the first request, the SDK transparently patches the router's dispatch so rejected handler promises are forwarded down the error chain — the exact semantics Express 5 ships natively — with the full request context (headers, body, data footprint, trace id) intact on the capture. Your own error middleware still runs and the client still gets its response; nothing is swallowed. On Express 5 the SDK detects native forwarding and does nothing.
One limitation: a router imported from a different physical copy of express
in node_modules (rare — npm normally dedupes to one) isn't covered by the outer
app's patch; mount sdk.requestHandler() inside that sub-app to cover its copy too.
Commands
npm run build
npm run lint
npm test