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"
}
func handleLogin(c *gin.Context) {
e := event.Current(c.Request.Context())
e.Action = "user.login"
}
func handleLogin(c echo.Context) error {
e := event.Current(c.Request().Context())
e.Action = "user.login"
return c.String(http.StatusOK, "ok")
}
func handleLogin(c fiber.Ctx) error {
e := event.Current(c.Context()) // not event.Current(c), see below
e.Action = "user.login"
return c.SendString("ok")
}
func (s *server) Login(ctx context.Context, req *pb.LoginRequest) (*pb.LoginReply, error) {
e := event.Current(ctx) // stream: event.Current(stream.Context())
e.Action = "user.login"
return &pb.LoginReply{}, nil
}
Pass the context, not the framework object.
event.Current(c)compiles, since gin's*gin.Contextand fiber'sfiber.Ctxboth satisfycontext.Context, but returns a throwaway that is never recorded. fiber'sValuereads itsLocals, not the Go context chain, so itsActorResolveralso receivesc.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.