Go SDK · Middleware and interceptors

An adapter threads a per-request Event through your handler chain, auto-populates Origin from the request and Result from the outcome, and records the event once the handler returns. Handlers enrich the event with Action / Target without ever calling the recorder themselves.

All adapters live in pkg/event and take the same event.Options.

Framework Mount
net/http, chi, gorilla/mux event.Middleware
gin event.GinMiddleware
echo v4 event.EchoV4Middleware
fiber v3 event.FiberV3Middleware
grpc-go event.UnaryInterceptor, event.StreamInterceptor

Options

type Options struct {
    ActorResolver ActorResolver // nil yields an anonymous actor
    Recorder      Recorder      // nil installs the event but does not auto-record
    Logger        Logger        // nil defaults to slog.Default()
}

Actor Resolver

The adapter needs an ActorResolver. A function that derives the Actor for a request from its context. Sessions live in cookies, JWTs, headers, OAuth tokens. Whatever your auth stack uses. Pair the resolver with a session middleware that plants identity on the request context before the audit adapter runs.

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-User-ID")
        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 adapter.
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"}
    }
}

ActorResolver signature:

type ActorResolver func(ctx context.Context) event.Actor

Wiring it up

Session middleware first, audit adapter second, then your routes. The resolver runs inside the adapter and reads what withSession planted:

auditMW := event.Middleware(event.Options{
    ActorResolver: actorResolver(users),
    Recorder:      rec,
})
apiHandler := withSession(auditMW(apiMux))

Ordering matters. If the audit adapter runs first, the resolver sees an empty context and every event records as anonymous.

Other frameworks

gin, echo, and fiber take the same Options and differ only in their mount signature:

r.Use(event.GinMiddleware(event.Options{ActorResolver: resolve, Recorder: rec}))
e.Use(event.EchoV4Middleware(event.Options{ActorResolver: resolve, Recorder: rec}))
app.Use(event.FiberV3Middleware(event.Options{ActorResolver: resolve, Recorder: rec}))

chi and gorilla/mux have no dedicated function because they need none. Both build on the same func(http.Handler) http.Handler signature as net/http, so event.Middleware mounts on them directly.

Reaching the event

Reach the request-scoped event with event.Current, passing the request context. How you get that context differs per framework:

// also chi, gorilla/mux
func handleLogin(w http.ResponseWriter, r *http.Request) {
    e := event.Current(r.Context())
    e.Action = "user.login"
}

Pass the context, not the framework object. event.Current(c) compiles, since gin's *gin.Context and fiber's fiber.Ctx both satisfy context.Context, but returns a throwaway that is never recorded. fiber's Value reads its Locals, not the Go context chain, so its ActorResolver also receives c.Context().

Naming the event

Reach the request-scoped event with event.Current and give it an Action. The adapter records it once the handler returns, so there is no Record call in your handler:

func handleLockUser(w http.ResponseWriter, r *http.Request) {
    e := event.Current(r.Context())
    e.Action = "user.lock"
    e.Target = event.Target{Type: "user", ID: r.PathValue("id")}
    // no Record call: the adapter records e when this handler returns
}

An event the handler never names is never recorded. A handler that early-returns without setting Action emits nothing, so you do not get garbage events from paths that decided nothing.

Handlers that emit several events per request use event.NewFromContext for the extras. It returns an independent clone with a fresh ID that inherits Actor and Origin, and you record those yourself. event.Current returns the one event the adapter owns.

gRPC

The gRPC interceptors take the same Options and mount on the server:

srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(event.UnaryInterceptor(event.Options{
        ActorResolver: resolve,
        Recorder:      rec,
    })),
    grpc.ChainStreamInterceptor(event.StreamInterceptor(event.Options{
        ActorResolver: resolve,
        Recorder:      rec,
    })),
)

Two differences from the HTTP adapters are worth knowing.

Every RPC is recorded by default. The interceptors set Action to the full method name (for example /billing.v1.Billing/RefundInvoice), so an RPC records unless the handler clears it. The HTTP adapters record nothing until a handler names the event.

Result.Code carries the HTTP equivalent of the gRPC status, not the native gRPC code. OK records as 200, PermissionDenied as 403, NotFound as 404. This keeps result.code filters and dashboards working the same way across both protocols, since a native OK is code 0 and would be indistinguishable from an unset code.

One behavioral difference in fiber

gin, echo, and the stdlib adapter can all tell "the handler returned having written nothing" apart from "the handler wrote a 200", and record the former as an error. Fiber cannot: fasthttp exposes no written-signal, and its status defaults to 200 whether or not a handler wrote. So a handler that returns without writing records as ok / 200 under fiber and as an error under the others. Panics are still detected correctly in all four.