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

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()
}

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"}
    }
}

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))

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})),
)

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})
    }
}

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)
    }
}

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"))

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 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
)

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

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)
    }
}

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.