net/http — ImmutableLog
Middleware for the Go standard library. Compatible with any router that accepts http.Handler — gorilla/mux, chi, native ServeMux. Zero dependencies beyond stdlib.
Middleware code
Create an immutablelog/middleware.go file in your project. Uses only the Go standard library — no external dependencies.
package immutablelog
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// contextKey is unexported to avoid collisions with other packages.
type contextKey string
const EventNameKey contextKey = "imtbl.eventName"
type Options struct {
APIKey string
ServiceName string
Env string
APIURL string
SkipPaths []string
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Status() int {
if rw.statusCode == 0 {
return http.StatusOK
}
return rw.statusCode
}
// Middleware wraps an http.Handler and sends an audit event to ImmutableLog
// for every request not in SkipPaths. The event is sent asynchronously —
// it never blocks the HTTP response.
func Middleware(opts Options) func(http.Handler) http.Handler {
if opts.APIURL == "" {
opts.APIURL = "https://api.immutablelog.com"
}
if opts.APIKey == "" {
opts.APIKey = os.Getenv("IMTBL_API_KEY")
}
if opts.ServiceName == "" {
opts.ServiceName = os.Getenv("IMTBL_SERVICE_NAME")
}
if opts.Env == "" {
opts.Env = os.Getenv("IMTBL_ENV")
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip health checks and monitoring paths
for _, p := range opts.SkipPaths {
if strings.HasPrefix(r.URL.Path, p) {
next.ServeHTTP(w, r)
return
}
}
startedAt := time.Now()
// Buffer the request body so downstream handlers can still read it
var bodyBytes []byte
if r.Body != nil {
bodyBytes, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
// Wrap the response writer to capture the status code
wrapped := &responseWriter{ResponseWriter: w}
next.ServeHTTP(wrapped, r)
// Fire-and-forget — never blocks the response
go emit(r, wrapped.Status(), time.Since(startedAt), bodyBytes, opts)
})
}
}
type eventPayload struct {
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
LatencyMs int64 `json:"latency_ms"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent,omitempty"`
RequestBodyHash string `json:"request_body_hash,omitempty"`
}
type eventMeta struct {
Type string `json:"type"`
EventName string `json:"event_name"`
Service string `json:"service"`
Env string `json:"env"`
RequestID string `json:"request_id"`
}
type eventBody struct {
Payload string `json:"payload"`
Meta eventMeta `json:"meta"`
}
func emit(r *http.Request, status int, elapsed time.Duration, body []byte, opts Options) {
requestID := r.Header.Get("X-Request-Id")
if requestID == "" {
requestID = fmt.Sprintf("%d", time.Now().UnixNano())
}
// Custom event name via context (set by a handler before responding)
eventName, _ := r.Context().Value(EventNameKey).(string)
if eventName == "" {
eventName = strings.ToLower(r.Method) + "." +
strings.ReplaceAll(strings.TrimPrefix(r.URL.Path, "/"), "/", ".")
}
kind := "success"
if status >= 400 {
kind = "error"
} else if status >= 300 {
kind = "info"
}
p := eventPayload{
Method: r.Method,
Path: r.URL.Path,
Status: status,
LatencyMs: elapsed.Milliseconds(),
ClientIP: clientIP(r),
UserAgent: r.UserAgent(),
}
if len(body) > 0 {
p.RequestBodyHash = sha256Hex(body)
}
payloadJSON, err := json.Marshal(p)
if err != nil {
return
}
evt := eventBody{
Payload: string(payloadJSON),
Meta: eventMeta{
Type: kind,
EventName: eventName,
Service: opts.ServiceName,
Env: opts.Env,
RequestID: requestID,
},
}
evtJSON, err := json.Marshal(evt)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
opts.APIURL+"/v1/events", bytes.NewBuffer(evtJSON))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+opts.APIKey)
req.Header.Set("Idempotency-Key", requestID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.SplitN(xff, ",", 2)[0]
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
return r.RemoteAddr
}
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return fmt.Sprintf("%x", h)
}Server registration
package main
import (
"net/http"
"github.com/yourorg/yourapp/immutablelog"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/users", usersHandler)
mux.HandleFunc("/payments", paymentsHandler)
mux.HandleFunc("/health", healthHandler)
audit := immutablelog.Middleware(immutablelog.Options{
APIKey: "iml_live_xxxx", // or use env IMTBL_API_KEY
ServiceName: "my-api",
Env: "production",
SkipPaths: []string{"/health", "/metrics"},
})
http.ListenAndServe(":8080", audit(mux))
}How it works
1. responseWriter wrapper
http.ResponseWriter doesn't expose the status code after being sent. The wrapper intercepts WriteHeader() and stores the code for the middleware to read after next.ServeHTTP().
2. io.NopCloser
The HTTP body is a stream read only once. The middleware reads it with io.ReadAll() and restores it with io.NopCloser() so downstream handlers can read it normally.
3. go emit()
The event is sent in a goroutine with go emit(). The client response was already sent. context.WithTimeout(5s) prevents the goroutine from hanging indefinitely.
4. contextKey tipada
The context key is a private type (type contextKey string) to avoid collisions with other libraries using context.WithValue().
Custom event
Use context.WithValue() in the handler to define a semantic event name. The middleware reads the value after next.ServeHTTP().
package handlers
import (
"context"
"net/http"
"github.com/yourorg/yourapp/immutablelog"
)
func PaymentHandler(w http.ResponseWriter, r *http.Request) {
// Set custom event name — middleware reads it via context after ServeHTTP
ctx := context.WithValue(r.Context(), immutablelog.EventNameKey, "payment.created")
r = r.WithContext(ctx)
// ... process payment ...
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"ok":true}`))
}Router compatibility
The middleware returns func(http.Handler) http.Handler — the most common pattern in the Go ecosystem. Works with any router following this interface.
// Works with any http.Handler-compatible router:
// Standard library ServeMux
http.ListenAndServe(":8080", audit(mux))
// gorilla/mux
router := mux.NewRouter()
http.ListenAndServe(":8080", audit(router))
// chi
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return audit(next)
})
http.ListenAndServe(":8080", r)Panic recovery
Add a separate recover middleware. The audit captures the status code even in case of panic, because next.ServeHTTP() already returned before the recover.
// Place recover AFTER audit in the chain so audit sees the panic status.
// With net/http, panics bubble up — add a recover middleware explicitly.
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Chain: recover → audit → handler
// audit fires after the response is written (even after a panic)
handler := RecoverMiddleware(audit(mux))This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
