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
snapshotfunction. Safer for resources where the sensitive field is large, structured, or easy to forget. The vault examples take this approach:snapshot()always returnsvalue: "[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 newFromContext() once per event you want to record. It takes no arguments: the per-request template lives in AsyncLocalStorage, so the current request is already implied. 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 { newFromContext } 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 newFromContext() call yields a
// fresh clone with actor + origin already filled in.
for (const uid of userIds) {
const e = newFromContext();
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.