Recording Events
With an account, project, and API key in hand, you're ready to wire the SDK into your application. This page covers installing it, telling it about your credentials, and recording your first event.
Install the SDK
Pick the SDK that matches your stack.
go get github.com/everscribe/sdk-go
npm install @everscribe/sdk-node
Don't see an SDK for your stack? Open a request.
Export your credentials
Both SDKs read the project ID and API key from environment variables. Set them in your shell before running your app — and in production, source them from your secrets manager.
export EVERSCRIBE_PROJECT_ID=proj_...
export EVERSCRIBE_API_KEY=es_live_...
Initialize the Recorder
The minimal way to confirm your credentials work end-to-end is a standalone program:
package main
import (
"context"
"log"
"os"
"github.com/everscribe/sdk-go"
"github.com/everscribe/sdk-go/pkg/event"
)
func main() {
projectID := os.Getenv("EVERSCRIBE_PROJECT_ID")
apiKey := os.Getenv("EVERSCRIBE_API_KEY")
es, err := everscribe.New(projectID, apiKey)
if err != nil {
log.Fatal(err)
}
rec := es.NewRecorder()
defer rec.Close()
}
import { create } from "@everscribe/sdk-node";
const projectId = process.env.EVERSCRIBE_PROJECT_ID!;
const apiKey = process.env.EVERSCRIBE_API_KEY!;
const es = create(projectId, apiKey);
const recorder = es.recorder();
Both SDKs buffer events in memory and flush in the background — Record is non-blocking by design.
Define Your Actor Resolver
In production you'll record events from inside request handlers. Both SDKs ship an HTTP middleware that attaches a per-request Event to the request context, auto-populates Origin (IP, user-agent, request ID), and lets your handlers enrich the event with Action, Target, and anything else specific to the request — without threading the recorder through each one.
The middleware needs one thing from you: an ActorResolver — a function that derives the Actor for a request from its context. The actor's identity must already be on the context when the resolver runs; that's the job of a session middleware that runs before the audit middleware. Sessions live in cookies, JWTs, headers, OAuth tokens — whatever your auth stack uses.
The pair below — session middleware plus matching resolver — is lifted from the secrets-vault examples repo. It uses an X-Demo-Actor header for clarity; swap that for your real auth source.
import (
"context"
"net/http"
"github.com/everscribe/sdk-go/pkg/event"
)
type actorIDKey struct{}
// withSession plants the actor's identity on the request context.
// Real apps read a session cookie or JWT here.
func withSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Demo-Actor")
ctx := context.WithValue(r.Context(), actorIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// actorResolver reads the identity withSession planted and turns it
// into an Actor for the audit middleware.
func actorResolver(users map[string]User) event.ActorResolver {
return func(ctx context.Context) event.Actor {
id, _ := ctx.Value(actorIDKey{}).(string)
if u, ok := users[id]; ok {
return event.Actor{
Type: "user",
ID: u.ID,
DisplayName: u.Name,
Email: u.Email,
}
}
return event.Actor{Type: "anonymous"}
}
}
import type { RequestHandler } from "express";
import { type ActorResolver } from "@everscribe/sdk-node/express";
declare module "express-serve-static-core" {
interface Request { user?: User }
}
// withSession plants the user on req. Real apps read a session cookie
// or JWT here.
const withSession: RequestHandler = (req, _res, next) => {
const id = req.headers["x-demo-actor"];
if (typeof id === "string") req.user = users[id];
next();
};
// resolveActor reads req.user (set by withSession) and turns it into
// an Actor for the audit middleware.
const resolveActor: ActorResolver = (req) => {
if (!req.user) return { type: "anonymous" };
return {
type: "user",
id: req.user.id,
displayName: req.user.name,
email: req.user.email,
};
};
Wire the chain — session middleware first, audit middleware second, then your routes. The audit resolver runs inside the audit middleware and reads what withSession planted:
auditMW := event.NewMiddleware(actorResolver(users))
apiHandler := withSession(auditMW(apiMux))
import { expressMiddleware } from "@everscribe/sdk-node/express";
app.use(express.json());
app.use(withSession); // session first
app.use(expressMiddleware({ recorder: rec, resolveActor }));
Ordering matters. If the audit middleware runs first, the resolver sees an empty context and every event records as anonymous.
Start Recording
With the recorder constructed and the middleware wired, your handlers can record events. The examples below are lifted from the secrets-vault examples repo. One tiny SDK difference to keep in mind: in Go you tell the middleware to record via defer rec.Record(...) inside each handler; in Node the middleware records automatically on response finish.
Simple write
func revealSecret(v *vault, rec recorder.Recorder) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
me, ok := actorFromRequest(r)
if !ok {
http.Error(w, "unknown actor", http.StatusUnauthorized)
return
}
id := r.PathValue("id")
e := event.FromContext(r.Context())
e.TenantID = me.TenantID
e.Target = event.Target{Type: "secret", ID: id}
defer func() { _ = rec.Record(r.Context(), e) }()
s, err := v.reveal(me.TenantID, id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
e.Action = "secret.reveal"
e.WithFields("name", s.Name)
writeJSON(w, http.StatusOK, map[string]string{"value": s.Value})
}
}
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 });
});
Both versions capture the final state of the request regardless of which branch ran. 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 diff field so the audit trail shows what changed, not just that something did:
func rotateSecret(v *vault, rec recorder.Recorder) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
me, _ := actorFromRequest(r)
id := r.PathValue("id")
e := event.FromContext(r.Context())
e.TenantID = me.TenantID
e.Target = event.Target{Type: "secret", ID: id}
defer func() { _ = rec.Record(r.Context(), e) }()
var in struct{ Value string `json:"value"` }
json.NewDecoder(r.Body).Decode(&in)
before := snapshot(v.get(id)) // pre-mutation state
v.rotate(id, in.Value)
after := snapshot(v.get(id)) // post-mutation state
e.Action = "secret.rotate"
e.Diff(before, after) // populates e.Change with the redacted diff
w.WriteHeader(http.StatusNoContent)
}
}
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 redacted 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 the event's change field. The vault example pre-redacts sensitive fields inside snapshot() so the diff never sees plaintext; the next section shows an alternative.
Redacted fields
The diff helper takes a WithRedactedFields / withRedactedFields option that replaces sensitive paths with "[REDACTED]" before the diff is stored. Paths are JSON pointers — /password_hash, /api_keys/0, /billing/credit_card:
e.Diff(before, after,
event.WithRedactedFields("/password_hash", "/api_keys/0"))
import { withRedactedFields } from "@everscribe/sdk-node/event";
req.event!.diff(before, after,
withRedactedFields("/password_hash", "/api_keys/0"));
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 flush_interval or when flush_size 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:
rec := es.NewRecorder(
recorder.WithBufferSize(5000), // in-memory capacity
recorder.WithFlushSize(500), // flush at this many pending events
recorder.WithFlushInterval(2*time.Second), // …or this often, whichever first
)
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:
defer rec.Close() // flushes pending events, then disposes
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 event.FromContext (Go) or fromContext (Node) 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.
func shareSecret(v *vault, rec recorder.Recorder) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
me, _ := actorFromRequest(r)
id := r.PathValue("id")
var in struct{ UserIDs []string `json:"user_ids"` }
json.NewDecoder(r.Body).Decode(&in)
v.share(me.TenantID, id, in.UserIDs)
// One audit event per recipient. Each FromContext call yields a
// fresh clone with Actor + Origin already filled in.
for _, uid := range in.UserIDs {
e := event.FromContext(r.Context())
e.TenantID = me.TenantID
e.Action = "secret.share"
e.Target = event.Target{Type: "secret", ID: id}
e.WithFields("shared_with", uid)
_ = rec.Record(r.Context(), e)
}
w.WriteHeader(http.StatusNoContent)
}
}
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();
});
Note that req.event (Node) and the auto-defer rec.Record(...) pattern (Go) are still about one event per request — the implicit one tied to the middleware lifecycle. The clones above are recorded explicitly, in addition to (or instead of) that primary event. If you don't want a primary event at all, just don't set req.event!.action (Node) or skip the defer rec.Record(...) (Go); the middleware skips events with no action set.