Skip to main content
ImmutableLog logo
Back
Gonet/http

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.

go
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"
const TrailKey contextKey = "imtbl.trail"

type Options struct {
	APIKey      string
	ServiceName string
	Env         string
	APIURL      string
	SkipPaths   []string
	// Timezone do cliente — usado pelo dashboard para exibir os timestamps.
	// Client timezone — used by the dashboard to render timestamps.
	ClientTZ            string
	ClientOffsetMinutes int
}

// sanitizeTrail normaliza o immutable_trail conforme as regras do servidor:
// trim, nao-vazio, max 256 chars, sem ':' (o servidor responde 400 se violado).
func sanitizeTrail(v string) string {
	v = strings.TrimSpace(v)
	if v == "" {
		return ""
	}
	v = strings.ReplaceAll(v, ":", "-")
	if len(v) > 256 {
		v = v[:256]
	}
	return v
}

// 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")
	}
	if opts.ClientTZ == "" {
		opts.ClientTZ = "America/Sao_Paulo"
	}
	if opts.ClientOffsetMinutes == 0 {
		opts.ClientOffsetMinutes = -180
	}

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

// Todos os valores de meta precisam ser strings (omitempty para opcionais).
// Every meta value must be a string (omitempty for optional fields).
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"`
	ImmutableTrail string `json:"immutable_trail,omitempty"`
}

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, "/"), "/", ".")
	}

	// Trilha imutavel: context TrailKey -> header X-Imtbl-Trail.
	trail, _ := r.Context().Value(TrailKey).(string)
	trail = sanitizeTrail(trail)
	if trail == "" {
		trail = sanitizeTrail(r.Header.Get("X-Imtbl-Trail"))
	}

	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,
			ImmutableTrail: trail,
		},
	}

	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)
	// Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
	req.Header.Set("Idempotency-Key", eventName+"-"+requestID)
	req.Header.Set("Request-Id", requestID)
	req.Header.Set("X-Client-TZ", opts.ClientTZ)
	req.Header.Set("X-Client-Offset-Minutes", fmt.Sprintf("%d", opts.ClientOffsetMinutes))
	if trail != "" {
		req.Header.Set("X-Imtbl-Trail", trail)
	}

	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

go
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().

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

Immutable trail (immutable_trail)

A trail groups related events into a single auditable timeline. Set it via context.WithValue(r.Context(), immutablelog.TrailKey, ...) in the handler or propagate it across services with the X-Imtbl-Trail header.

go
func RunFlowHandler(w http.ResponseWriter, r *http.Request) {
	ctx := context.WithValue(r.Context(), immutablelog.TrailKey, "flow-123")
	r = r.WithContext(ctx)
	// ... business logic ...
	w.WriteHeader(http.StatusOK)
}

The trail cannot be empty, exceed 256 characters, or contain ":" — violations return 400 invalid_immutable_trail. sanitizeTrail fixes the value before sending.

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.

go
// 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.

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

meta fields

Beyond the payload, the meta object carries the fields the SIEM uses to normalize (ECS) and enrich the event. All meta values are strings.

Field in metaWhat it does in the SIEM
event_nameThe event label (e.g. http.GET.auth-me-user, Payment error) — the identifier shown in the listing. Becomes event.action and derives event.category (e.g. a name with loginauthentication).
client_ipBecomes source.ip with maximum precedence and provenance client_asserted. See the section below.
immutable_trailBecomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user).
typeEvent severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.)
service, env, request_idMetadata: indexed and queryable in the dashboard (search/filters).

client_ip — end-user IP

The problem it solves

Between the browser and the core there are proxies/ALB. The IP the core sees on the connection is the previous hop (the client's backend or an AWS proxy), not the user's. Without action, source.ip would be the proxy IP — useless for geo, threat and IP-based detection. (Real example: XFF arrived as 44.192.13.3 (AWS) while the user was 179.110.4.205.)

The solution

The client's backend is the only one that sees the browser's real IP — so it forwards that IP in meta.client_ip when calling POST /v1/events.

How the SIEM handles it

  • Validates it is an IP (v4/v6). A non-IP value is ignored — it does not drop the event.
  • Writes to source.ip with maximum precedence (see order below).
  • Stamps imtbl.source_ip_origin = "client_asserted" — the UI shows the “User IP” badge.
  • Feeds GeoIP, threat intel and IP-based detection rules.
source.ip precedence:client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IP

Provenance and trust

client_ip is asserted by the tenant — the client's backend claims the IP, and it is spoofable by whoever controls that backend. However it is self-contained to the tenant (it never crosses another tenant's boundary). A threat/geo hit on a client_asserted value is a claim, not an edge observation.

How to obtain the IP in your backend

Take the first IP from X-Forwarded-For (closest to the user); as a fallback, use the connection IP.

go
func clientIP(r *http.Request) string {
    if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
        return strings.TrimSpace(strings.Split(xff, ",")[0]) // primeiro hop
    }
    host, _, _ := net.SplitHostPort(r.RemoteAddr)
    return host
}

meta := map[string]string{"event_name": "user.login", "client_ip": clientIP(r)}

Payload example

json
{
  "payload": "{\"user\":\"bob\",\"action\":\"login\"}",
  "meta": {
    "event_name": "user.login",
    "client_ip": "179.110.4.205",
    "immutable_trail": "user-bob"
  }
}

PII note: client_ip is PII and gets sealed into the immutable block (permanent). Decide consciously between forensic evidence and “right to be forgotten” before sending it.

This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.