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
pip install everscribe
cargo add everscribe --features axum
Don't see an SDK for your stack? Open a request.
Export your credentials
All four 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();
import os
import everscribe
project_id = os.environ["EVERSCRIBE_PROJECT_ID"]
api_key = os.environ["EVERSCRIBE_API_KEY"]
es = everscribe.new(project_id, api_key)
rec = es.new_recorder()
# on shutdown: rec.close()
let project_id = std::env::var("EVERSCRIBE_PROJECT_ID")?;
let api_key = std::env::var("EVERSCRIBE_API_KEY")?;
let es = everscribe::new(project_id, api_key)?;
let rec = es.new_recorder(Default::default());
// on shutdown: rec.close().await?;
All four SDKs buffer events in memory and flush in the background; Record / record() is non-blocking by design.
Define Your Actor Resolver
In production you'll record events from inside request handlers. All four SDKs ship a framework middleware (HTTP for Go, Express for Node, ASGI for Python, tower/axum for Rust) 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,
};
};
from starlette.requests import Request
from everscribe.event import Actor
# The ASGI middleware passes the request to your resolver, so identity can
# come straight off it: a session cookie, a JWT, or (here) a demo header.
# There's no separate "session middleware" step — mount a real one before
# EverscribeMiddleware only if your resolver reads request.session.
def resolve_actor(request: Request) -> Actor:
user = users.get(request.headers.get("x-demo-actor", ""))
if user is None:
return Actor(type="anonymous")
return Actor(
type="user",
id=user.id,
display_name=user.name,
email=user.email,
)
use everscribe::event::Actor;
use http::request::Parts;
// The tower middleware passes the request Parts to your resolver, so identity
// can come off a session set by an earlier layer, a JWT, or (here) a header.
fn resolve_actor(parts: &Parts) -> Actor {
match parts.headers.get("x-demo-actor").and_then(|v| v.to_str().ok()) {
Some(id) => Actor { r#type: "user".into(), id: id.into(), ..Default::default() },
None => Actor::new("anonymous"),
}
}
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.Middleware(event.Options{
ActorResolver: actorResolver(users),
Recorder: rec,
})
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 }));
from everscribe.asgi import EverscribeMiddleware
app.add_middleware(EverscribeMiddleware, recorder=rec, resolve_actor=resolve_actor)
# If you resolve the actor from a session, add that middleware *after* this
# line — Starlette runs the last-added middleware first (outermost).
use everscribe::axum::EverscribeLayer;
let app = Router::new()
.merge(routes)
.layer(EverscribeLayer::new(rec, resolve_actor));
// A session layer your resolver reads must run first (outer); in axum, add it
// *after* this line.
Ordering matters. If the audit middleware runs first, the resolver sees an empty context and every event records as anonymous.
The tabs above show one mount per language. Every framework each SDK supports takes the same options, so only the mount line changes:
// Same Options for every adapter. Only the mount line changes.
r.Use(event.GinMiddleware(event.Options{ActorResolver: actorResolver(users), Recorder: rec}))
e.Use(event.EchoV4Middleware(event.Options{ActorResolver: actorResolver(users), Recorder: rec}))
app.Use(event.FiberV3Middleware(event.Options{ActorResolver: actorResolver(users), Recorder: rec}))
// gRPC servers use interceptors instead:
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(event.UnaryInterceptor(event.Options{ActorResolver: actorResolver(users), Recorder: rec})),
grpc.ChainStreamInterceptor(event.StreamInterceptor(event.Options{ActorResolver: actorResolver(users), Recorder: rec})),
)
// Same options for every adapter, imported from its own subpath
// (@everscribe/sdk-node/express, /fastify, /hono). Only the mount changes.
app.use(expressMiddleware({ recorder: rec, resolveActor })); // Express
app.register(fastifyMiddleware({ recorder: rec, resolveActor })); // Fastify
app.use(honoMiddleware({ recorder: rec, resolveActor })); // Hono
// gRPC servers use an interceptor instead (unary only):
new grpc.Server({ interceptors: [grpcServerInterceptor({ recorder: rec, resolveActor })] });
# ASGI covers FastAPI, Starlette, and any ASGI app (everscribe.asgi):
app.add_middleware(EverscribeMiddleware, recorder=rec, resolve_actor=resolve_actor)
# Flask (everscribe.flask):
EverscribeFlask(flask_app, recorder=rec, resolve_actor=resolve_actor)
# Django (everscribe.django) configures via settings, since Django
# instantiates middleware itself:
EVERSCRIBE_RECORDER = rec
MIDDLEWARE = [*MIDDLEWARE, "everscribe.django.EverscribeMiddleware"]
# gRPC (everscribe.grpc). Sync and async servers use different classes:
grpc.server(pool, interceptors=[EverscribeServerInterceptor(recorder=rec, resolve_actor=resolve_actor)])
grpc.aio.server(interceptors=[EverscribeAsyncServerInterceptor(recorder=rec, resolve_actor=resolve_actor)])
// Same constructor for every adapter, each behind its own cargo feature.
// axum (feature = "axum"), from everscribe::axum:
let app = Router::new().merge(routes).layer(EverscribeLayer::new(rec, resolve_actor));
// actix-web (feature = "actix"), from everscribe::actix:
let app = App::new().wrap(EverscribeLayer::new(rec, resolve_actor));
// gRPC (feature = "tonic"), a tower layer over your service:
let wrapped = EverscribeGrpcLayer::new(rec, resolve_actor).layer(grpc_service);
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. All four SDKs record automatically on response finish; you just name the event with Action / Target and the middleware takes care of sending it.
Simple write
func revealSecret(v *vault) 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.Current(r.Context())
e.TenantID = me.TenantID
e.Target = event.Target{Type: "secret", ID: id}
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 });
});
from everscribe.asgi import current_event
from everscribe.event import Target
@app.post("/api/secrets/{secret_id}/reveal")
async def reveal(secret_id: str, request: Request):
me = actor(request)
if me is None:
return PlainTextResponse("unknown actor", status_code=401)
e = current_event()
e.tenant_id = me.tenant_id
e.target = Target(type="secret", id=secret_id)
s = vault.get(secret_id)
if s is None or s.tenant_id != me.tenant_id:
return PlainTextResponse("not found", status_code=404)
e.action = "secret.reveal"
e.with_field("name", s.name)
return JSONResponse({"value": s.value})
use everscribe::axum::CurrentEvent;
use everscribe::event::Target;
async fn reveal(State(v): State<Vault>, ev: CurrentEvent, Path(id): Path<String>) -> Response {
ev.with(|e| e.target = Target::new("secret", &id));
let Some(s) = v.reveal(&id) else {
return (StatusCode::NOT_FOUND, "not found").into_response();
};
ev.with(|e| {
e.action = "secret.reveal".into();
e.with_field("name", &s.name);
});
Json(json!({ "value": s.value })).into_response()
}
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) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
me, _ := actorFromRequest(r)
id := r.PathValue("id")
e := event.Current(r.Context())
e.TenantID = me.TenantID
e.Target = event.Target{Type: "secret", ID: id}
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();
});
@app.post("/api/secrets/{secret_id}/rotate")
async def rotate(secret_id: str, request: Request):
me = actor(request)
e = current_event()
e.tenant_id = me.tenant_id
e.target = Target(type="secret", id=secret_id)
body = await request.json()
s = vault.get(secret_id)
if s is None:
return PlainTextResponse("not found", status_code=404)
before = snapshot(s) # pre-mutation state
s.value = body["value"]
s.rotated_at = now_iso()
after = snapshot(s) # post-mutation state
e.action = "secret.rotate"
e.diff(before, after) # populates e.change with the redacted diff
return Response(status_code=204)
async fn rotate(State(v): State<Vault>, ev: CurrentEvent, Path(id): Path<String>, Json(body): Json<Value>) -> Response {
ev.with(|e| e.target = Target::new("secret", &id));
let before = snapshot(&v.get(&id)); // pre-mutation state
v.rotate(&id, body["value"].as_str().unwrap_or_default());
let after = snapshot(&v.get(&id)); // post-mutation state
ev.with(|e| {
e.action = "secret.rotate".into();
e.diff(&before, &after); // populates e.change with the diff
});
StatusCode::NO_CONTENT.into_response()
}
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"));
from everscribe.event import with_redacted_fields
e.diff(before, after,
with_redacted_fields("/password_hash", "/api_keys/0"))
ev.with(|e| {
e.diff_redacted(&before, &after, &["/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,
});
rec = es.new_recorder(
buffer_size=5000, # in-memory capacity
flush_size=500, # flush at this many pending events
flush_interval=2.0, # …or this often (seconds), whichever first
)
use everscribe::recorder::RecorderOptions;
use std::time::Duration;
let rec = es.new_recorder(RecorderOptions {
buffer_size: 5000, // in-memory capacity
flush_size: 500, // flush at this many pending events
flush_interval: Duration::from_secs(2), // …or this often, whichever first
..Default::default()
});
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
rec.close() # flushes pending events, then disposes (call from your shutdown hook)
rec.close().await?; // 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.NewFromContext (Go), newFromContext() (Node), or new_from_context() (Python) once per event. The Node and Python forms take no arguments: the per-request template lives in async-local storage, so the current request is already implied. Each returns an independent clone of the per-request template (Actor, Origin pre-filled). These are extra events the adapter doesn't own. In Rust, build each Event directly with Event::new and copy the actor over. Record each 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 NewFromContext call yields a
// fresh clone with Actor + Origin already filled in.
for _, uid := range in.UserIDs {
e := event.NewFromContext(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 { 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();
});
from everscribe.event import new_from_context, Target
@app.post("/api/secrets/{secret_id}/share")
async def share(secret_id: str, request: Request):
me = actor(request)
body = await request.json()
user_ids = body.get("user_ids", [])
vault.share(me.tenant_id, secret_id, user_ids)
# One audit event per recipient. Each new_from_context() call yields a
# fresh clone with actor + origin already filled in.
for uid in user_ids:
e = new_from_context()
e.tenant_id = me.tenant_id
e.action = "secret.share"
e.target = Target(type="secret", id=secret_id)
e.with_fields("shared_with", uid)
rec.record(e)
return Response(status_code=204)
use everscribe::event::{Event, Target};
// `rec` is the recorder constructed at boot; `actor` came off the request.
for uid in user_ids {
let mut e = Event::new("secret.share");
e.actor = actor.clone();
e.target = Target::new("secret", &id);
e.with_field("shared_with", &uid);
rec.record(e).await.ok();
}
Note that the per-request event (req.event in Node, current_event() in Python, the CurrentEvent extractor in Rust, or event.Current(ctx) in Go) is 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 its action (req.event!.action in Node, current_event().action in Python, ev.with(|e| ...) in Rust, or the event returned by event.Current(ctx) in Go); the middleware skips events with no action set.