Go 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. Pull the per-request event from context, enrich it with Action / Target, and defer rec.Record(...) so the event captures the final state regardless of which branch ran:
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})
}
}
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:
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 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 Event.Change.
Redacted fields
e.Diff accepts an event.WithRedactedFields(...) option that replaces sensitive paths with "[REDACTED]" before the diff is stored. Paths are JSON pointers:
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
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 goroutine 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.FromContext 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)
}
}
Direct recording
Not every event comes from an HTTP request. Cron jobs, queue workers, CLI tools, startup hooks — anywhere the audit middleware isn't in the path, you construct each Event directly: set Actor to a service identity, set Result by hand since there's no response status to derive it from, and pass the caller's own context (not a request context) to Record.
// 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.
func rotateExpiringSecrets(ctx context.Context, v *vault, rec recorder.Recorder) {
for _, s := range v.dueForRotation() {
e := event.Event{
Action: "secret.rotate",
Actor: event.Actor{Type: "service", ID: "rotation-worker"},
Target: event.Target{Type: "secret", ID: s.ID},
Result: event.Result{Status: "ok"},
}
if err := v.rotate(s.ID, generate()); err != nil {
e.Result = event.Result{Status: "error", Message: err.Error()}
}
_ = rec.Record(ctx, e)
}
}
The same recorder instance backs both your HTTP 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.