Node SDK · Recorder

With the recorder constructed and the middleware wired, your handlers can record events. The examples below are lifted from the secrets-vault examples repo.

Simple write

The most common case — one event per request. Set fields on req.event and the middleware records on response finish. Handlers that don't set req.event.action are skipped entirely (no audit noise):

app.post("/api/secrets/:id/reveal", (req, res) => {
    const me = actor(req);
    if (!me) {
        res.status(401).send("unknown actor");
        return;
    }
    req.event!.tenantId = me.tenantId;
    req.event!.target = { type: "secret", id: req.params.id };

    const s = vault.get(req.params.id);
    if (!s || s.tenantId !== me.tenantId) {
        res.status(404).send("not found");
        return;
    }

    req.event!.action = "secret.reveal";
    req.event!.withFields("name", s.name);

    res.json({ value: s.value });
});

The middleware auto-fills result from the response status, so error paths and success paths both record correctly without you setting result by hand.

Mutation with before/after diff

For events that change a resource, populate the change field so the audit trail shows what changed, not just that something did:

app.post("/api/secrets/:id/rotate", (req, res) => {
    const me = actor(req)!;
    req.event!.tenantId = me.tenantId;
    req.event!.target = { type: "secret", id: req.params.id };

    const { value } = req.body ?? {};
    const s = vault.get(req.params.id);
    if (!s) {
        res.status(404).send("not found");
        return;
    }

    const before = snapshot(s);            // pre-mutation state
    s.value = value;
    s.rotatedAt = new Date().toISOString();
    const after = snapshot(s);             // post-mutation state

    req.event!.action = "secret.rotate";
    req.event!.diff(before, after);        // populates req.event.change with the diff

    res.status(204).send();
});

snapshot is your function — typically a struct that mirrors the resource. The diff helper computes the JSON patch between the two snapshots and stores it in event.change.

Redacted fields

req.event!.diff accepts a withRedactedFields(...) option that replaces sensitive paths with "[REDACTED]" before the diff is stored. Paths are JSON pointers:

import { withRedactedFields } from "@everscribe/sdk-node/event";

req.event!.diff(before, after,
    withRedactedFields("/passwordHash", "/billing/creditCard"));

Two approaches, pick whichever fits the resource shape:

  • Redact paths at diff time with withRedactedFields — shorter when you have a small known set of paths to strip from an otherwise-safe struct.
  • Pre-redact in your snapshot function — safer for resources where the sensitive field is large, structured, or easy to forget. The vault examples take this approach: snapshot() always returns value: "[REDACTED]" so the plaintext never even reaches the SDK.

Batch recording

The buffered recorder batches events under the hood. record() enqueues; a background loop sends batches every flushInterval or when flushSize is reached, whichever comes first. You don't need to call any batch API.

For high-throughput services, tune the batch behavior via constructor options:

const rec = es.newRecorder({
    bufferSize: 5000,
    flushSize: 500,
    flushInterval: 2_000,
});

For graceful shutdown, drain the buffer so pending events make it to the API before the process exits:

await rec.close();  // flushes pending events, then disposes

Multiple events per request

Some handlers naturally produce more than one event — sharing a secret with three recipients, fan-out notifications, bulk imports. Call fromContext(req) once per event you want to record. Each call returns an independent clone of the per-request template (actor, origin pre-filled); record each clone explicitly. The recorder batches them together on the next flush.

import { fromContext } from "@everscribe/sdk-node/event";

app.post("/api/secrets/:id/share", (req, res) => {
    const me = actor(req)!;
    const id = req.params.id;
    const { userIds = [] } = req.body ?? {};

    vault.share(me.tenantId, id, userIds);

    // One audit event per recipient. Each fromContext() call yields a
    // fresh clone with actor + origin already filled in.
    for (const uid of userIds) {
        const e = fromContext(req);
        e.tenantId = me.tenantId;
        e.action = "secret.share";
        e.target = { type: "secret", id };
        e.withFields("shared_with", uid);
        rec.record(e);
    }

    res.status(204).send();
});

Direct recording

Not every event comes from an HTTP request. Cron jobs, queue workers, CLI scripts, startup hooks — anywhere the audit middleware isn't in the path, you build each event yourself: set actor to a service identity and set result by hand since there's no response status to derive it from.

// rotateExpiringSecrets is a scheduled job that rotates any secret due
// for rotation. Runs out of band — no middleware, no request, just the
// recorder you constructed at boot.
async function rotateExpiringSecrets(rec: BufferedRecorder, vault: Vault) {
    for (const s of vault.dueForRotation()) {
        const e: Event = {
            action: "secret.rotate",
            actor: { type: "service", id: "rotation-worker" },
            target: { type: "secret", id: s.id },
            result: { status: "ok" },
        };
        try {
            await vault.rotate(s.id, generate());
        } catch (err) {
            e.result = { status: "error", message: String(err) };
        }
        await rec.record(e);
    }
}

The same recorder instance backs both your Express handlers and your background jobs — events from both paths land in the same project and batch together. Construct one recorder at boot, share it across both surfaces, and close() it once on shutdown.