Tracevault
Open source · MIT · Node.js + TypeScript

Custom audit events, consistently persisted.

A lightweight audit library for Node.js and TypeScript. Define your own events, map logical scopes to PostgreSQL tables, persist with strict validation and masking — then read with audit.query or raw SQL.

npm install tracevault pg
emit.ts
import { startTracevault } from "tracevault"

const audit = await startTracevault({
  driver: "postgres",
  connectionString: process.env.DATABASE_URL_WRITE!,
  defaultScope: "default",
  scopes: { default: { tableName: "audit_logs" } },
  bootstrap: { ensureSchema: true },
  maskFields: ["password", "token"],
})

await audit.emit({
  event: "product.price.updated",
  actor:  { id: "user_123",    type: "user" },
  target: { id: "product_456", type: "product" },
  data:   { oldPrice: 120, newPrice: 150, currency: "UYU" },
  meta:   { source: "admin-panel" },
})
What you get

Enough structure to query. No more.

Tracevault handles the boring-but-crucial parts of audit logging so you can focus on what each event means in your domain.

01

Custom events first

You name the events. You own data and meta. No prescribed catalog — Tracevault validates, masks, and normalizes what you send.

02

Structured by design

A small, stable persisted shape: event, actor, target, data, meta, timestamps, correlation. Enough to index and reason about. No more.

03

Named scopes

Map logical keys to physical tables at startup with scopes. Use getScope("users") for writes and getScope("users").query for reads — one app, shared pools.

04

Schema bootstrap

startTracevault can run idempotent DDL for every scoped table by default. Prefer bootstrap: { ensureSchema: false } when your migrations own the schema.

05

Integrated read API

audit.query on the same app object — equality filters, time windows, deterministic pagination, errorsOnly. No separate import path; exotic queries stay in SQL.

06

Optional diff helper

emitDiff computes a shallow field diff and stores { before, after, diff } as a normal event. Still just sugar around emit.

How it works

Five steps from zero to stored events.

The surface area stays small: one app, explicit scopes, predictable writes, flexible schema setup, and your choice of read path.

  1. 01

    Start the app

    await startTracevault({ scopes, defaultScope, … }). Optional readConnectionString or readPool for production read roles.

  2. 02

    Name your scopes

    List every physical table in scopes once. Use audit.emit on the default scope or audit.getScope("users") for other domains.

  3. 03

    Emit custom events

    Call emit() or emitDiff(). Correlation helpers keep request traces aligned across emits.

  4. 04

    Schema your way

    Let ensureSchema bootstrap tables, or apply sql/001–003 / generateInitSql yourself when migrations own DDL.

  5. 05

    Read your way

    Use audit.query or getScope(name).query — or pg, Drizzle, Prisma, Knex, raw SQL for everything else.

Examples

Core write API, in three snippets.

Named scopes, correlation helpers, schema bootstrap, and audit.query live in the docs — this section is the smallest happy path.

Start the app

await startTracevault with scopes and optional readConnectionString. maskFields applies recursively to data, meta, before and after.

audit.ts
import { startTracevault } from "tracevault"

const audit = await startTracevault({
  driver: "postgres",
  connectionString: process.env.DATABASE_URL_WRITE!,
  readConnectionString: process.env.DATABASE_URL_READ,
  defaultScope: "default",
  scopes: {
    default: { tableName: "audit_logs" },
    users: { tableName: "audit_user_events" },
  },
  bootstrap: { ensureSchema: true },
  maskFields: ["password", "token", "pin"],
  defaultMode: "sync",
  environment: process.env.NODE_ENV,
})

Emit a custom event

Only event is required. actor, target, data, meta, correlationId and requestId are optional — use what makes sense.

login.ts
await audit.emit({
  event: "auth.login.succeeded",
  actor:  { id: "user_123", type: "user" },
  meta:   { ip: "127.0.0.1", userAgent: "curl/8" },
  correlationId: "req_abc",
})

Emit an object change

emitDiff is sugar around emit. It computes a shallow field diff and persists { before, after, diff } as the event payload.

update.ts
await audit.emitDiff({
  event: "product.updated",
  actor:  { id: "user_123",    type: "user" },
  target: { id: "product_456", type: "product" },
  before: { name: "Café", price: 120 },
  after:  { name: "Café", price: 150 },
})

// Persisted data:
// {
//   "before": { "name": "Café", "price": 120 },
//   "after":  { "name": "Café", "price": 150 },
//   "diff":   { "price": { "before": 120, "after": 150 } }
// }

For named scopes, schema options, and audit.query, see Documentation.

Philosophy

A library, not a framework.

Most audit tools push a worldview. Tracevault stays out of your way and gives you a reliable place to land events you already know how to name.

  • 01

    Does not impose an event catalog.

  • 02

    Does not require an ORM.

  • 03

    Does not try to be a framework.

  • 04

    Stays minimal, typed, and predictable.

  • 05

    Keeps audit.query narrow on purpose — no query DSL.

Tracevault does not define your event catalog. It gives you a consistent, reliable way to store your custom audit events. When you need indexed reads, audit.query stays deliberately narrow — the rest belongs in SQL you already trust.
Upgrading

Migrating from 0.x

1.x unifies writes and reads on one app object. The schema and event shape are unchanged — mostly import and config renames.

  • createTracevault(config)await startTracevault({ scopes, defaultScope, … })
  • root.scope({ tableName })scopes at startup + audit.getScope("name")
  • import from tracevault/queryaudit.query and getScope(name).query on the same app
  • Manual SQL onlybootstrap.ensureSchema: true by default (or keep your migrations)

Full release notes and breaking changes: CHANGELOG. Next: Install and Writing.