tracelapse
v0.9.0
Published
A timelapse of every request — full request stack trace (traceId + before/after of each table change) for Node.js.
Maintainers
Keywords
Readme
🛰️ Tracelapse
A timelapse of every request.
Full request stack trace for Node.js — one traceId per request (à la AWS X-Ray / x-amzn-RequestId)
that ties what came in (HTTP) to everything that changed in your database (before/after of each row)
to what went out (response). See exactly what a request did — and time-travel any row.
POST /transfers x-trace-id: 01J9…ABC 201 · 142ms
├─ http.in POST /transfers user: u-42 · ip: 1.2.3.4
├─ db.update accounts#42 balance 100 → 70 (-30)
├─ db.insert ledger_entries#991 { amount: 30, type: "debit" }
├─ db.update accounts#88 balance 0 → 30 (+30)
├─ event transfer.completed { from: 42, to: 88, amount: 30 }
└─ http.out 201Give support a
traceIdand they reconstruct the whole story: the request, every row it touched (with the past and future of each one), the domain events, and the response — in order.
Why
- One id correlates everything. A
traceIdis generated per request (ULID, returned inx-trace-id) and propagated viaAsyncLocalStorage— your service code and the ORM subscriber all see it, with zero plumbing. - It goes into the database, not just the HTTP edge. Every
INSERT/UPDATE/DELETEis captured with the row's before (past) and after (future) state, plus a diff. - Time-travel any row. "Show me every state
accounts#42has been in, and which request changed it." - Safe by default. Sensitive fields are redacted at write time; logging is best-effort and never breaks a request.
- Plug-and-play, framework-agnostic core. One package, opt-in adapters, optional peer deps.
Install
One package; import only the subpath you need (your stack's deps are optional peers):
npm i tracelapse # you already have @nestjs/*, typeorm, pg, rxjs, reflect-metadataimport { TracelapseModule } from 'tracelapse/nestjs'; // transport: traceId + interceptor
import { TypeOrmSink, TypeOrmTraceStore } from 'tracelapse/typeorm'; // capture + persistence (Postgres)
import { MongoTraceSink } from 'tracelapse/mongo'; // (optional) store logs in Mongo
import { buildTimeline, traceContext } from 'tracelapse'; // agnostic coreQuickstart — NestJS + TypeORM + Postgres
// 1) register the subscriber on your DataSource
import { TraceSubscriber, TraceEntity, TraceChangeEntity } from 'tracelapse/typeorm';
export const AppDataSource = new DataSource({
/* ...your config */
entities: [/* ...your entities */, TraceEntity, TraceChangeEntity],
subscribers: [TraceSubscriber],
});
// 2) wire the module (sync forRoot, or forRootAsync with @nestjs/typeorm)
@Module({
imports: [
TracelapseModule.forRoot({
sink: new TypeOrmSink(AppDataSource), // writes trace / trace_change
store: new TypeOrmTraceStore(AppDataSource), // enables reads — expose them with your guards (see below)
userIdFrom: 'user.sub', // associate the trace to a user
persist: { level: 'full' }, // full | standard | minimal
buffered: true, // async writes — off the response path (default)
}),
],
})
export class AppModule {}Create the tables (pick one):
# reads DATABASE_URL from the environment (inline, exported, or an env file)
DATABASE_URL=postgres://… npx tracelapse-migrate # inline
npx tracelapse-migrate # auto-loads ./.env
npx tracelapse-migrate --env-file .env.prod # pick the file (.env.local, .env.prod, any name)
# or add CreateTraceTables1780000000000 to your migrations
# or apply createTraceTablesSql() yourselfThat's it. Now any write inside a request is captured:
curl -i -X POST localhost:3000/transfers -d '{"from":42,"to":88,"amount":30}'
# → response header: x-trace-id: 01J9…ABCRead the traces (secure by default)
store enables reconstruction + time-travel, but Tracelapse does not mount any read endpoint by default — it's forensic data and the lib can't know your auth. Expose it from your own guarded controller, injecting the exported TRACE_QUERY_SERVICE:
import { TRACE_QUERY_SERVICE } from 'tracelapse/nestjs';
import type { TraceQueryService } from 'tracelapse';
@UseGuards(JwtAuthGuard, AdminGuard) // your guards, your route — fail-closed
@Controller('traces')
export class TraceReadController {
constructor(@Inject(TRACE_QUERY_SERVICE) private readonly traces: TraceQueryService) {}
@Get(':id') get(@Param('id') id: string) { return this.traces.getTrace(id); }
@Get('row/:table/:id') row(@Param('table') t: string, @Param('id') id: string) { return this.traces.getRowHistory(t, id); }
}curl -H "authorization: Bearer …" localhost:3000/traces/01J9…ABC # request + timeline (before/after + events)
curl -H "authorization: Bearer …" localhost:3000/traces/row/accounts/42 # time-travel: every state of that rowPrefer this over mounting the lib's built-in controller and guarding it with a global
APP_GUARDkeyed on a path prefix (req.path.startsWith('/traces')): if the prefix ever drifts (global prefix, versioning, route change) the guard silently no-ops and the API fails open. Owning the route keeps it fail-closed.Escape hatch: if the route is already protected upstream (gateway/dev),
mountReadApi: truemounts the lib's controller (GET /traces/:id, time-travel) for you — unauthenticated, so only behind your own protection.
Read your traces with AI (MCP)
Point your AI client (Claude Desktop/Code, …) at the built-in MCP server and just ask: "reconstruct trace 01J9…ABC and tell me what changed in account 42."
# local (stdio)
DATABASE_URL=postgres://readonly:***@host/db npx tracelapse-mcp
# remote (HTTP, authenticated — Bearer token required)
DATABASE_URL=… TRACELAPSE_MCP_PORT=7070 TRACELAPSE_MCP_TOKEN=$(openssl rand -hex 32) npx tracelapse-mcpTools: get_trace, get_row_history, search_traces (by user / fingerprint / method / status code / period).
Read-only and redacted. See docs/09-mcp.md.
What gets captured
| Per request (trace) | Per row change (trace_change) | Domain events |
|---|---|---|
| traceId, method, endpoint, actionName | seq, tableName, operation | name |
| userId, fingerprint | primaryKey | data |
| ip, visitorId, userAgent | before (past) · after (future) · diff | capturedAt |
| requestData, responseData (redacted) | source (orm | db) | |
| statusCode, errorMessage, executionTimeMs | capturedAt | |
Indexed for fast queries: traceId, userId, fingerprint, method, statusCode, createdAt.
Configure
| Option | What it does |
|---|---|
| headerName | correlation header (default x-trace-id) |
| traceMethods / excludeMethods | trace only some HTTP methods, or skip some |
| userIdFrom | where to read the user id (dot-path or (req) => id) |
| fingerprint | derive a request fingerprint ((req) => string) |
| redactOptions / redact | extra sensitive fields, or a custom redactor |
| persist | how much to save: full · standard · minimal (+ per-field overrides) |
| buffered | async writes (default true) — never adds latency to the response |
| sinks: [...] | fan-out to several sinks (e.g. DB + your own OpenTelemetry exporter) |
| mountReadApi | mount the lib's read controller (default true); set false to expose TRACE_QUERY_SERVICE from your own guarded controller |
Storage adapters
The capture is database-agnostic — TraceSink/TraceStore are the seam:
tracelapse/typeorm— Postgres (trace/trace_changetables), triggers for raw-SQL capture, retention helpers.tracelapse/prisma— Prisma Client Extension for capture (prisma.$extends(createTracelapseExtension())) + sink/store over the same tables. For full before/after on updates, pair with the Postgres triggers (ORM-agnostic).tracelapse/mongo— one document per trace; app can run on Postgres and ship logs to Mongo.- Your own — implement
TraceSink(and optionallyTraceStore) for Kafka, files, anything. The snapshot is plain, serializable data.
Documentation
| | | |---|---| | Vision & problem | Architecture | | Data model | Trace lifecycle | | Extensibility | Storage & rotation | | Read via MCP | Runnable example |
Built test-first (DDD-ish + strict TDD). npm test · npm run build.
License
Apache-2.0 © QTV Group
