Skip to main content
ImmutableLog logo
Back
GoGin

Gin — ImmutableLog

Middleware for the Gin framework. Uses c.Next() to capture status after all handlers, goroutine fire-and-forget to never block the response, and c.Set() for custom events.

Middleware code

Create an immutablelog package in your project. Returns gin.HandlerFunc and can be registered with r.Use() globally or on route groups.

go
package immutablelog

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
)

const ginEventNameKey = "imtbl.eventName"

type Config struct {
	APIKey      string
	ServiceName string
	Env         string
	APIURL      string
	SkipPaths   []string
}

// Middleware returns a gin.HandlerFunc that sends an audit event to ImmutableLog
// for every request. The event is sent asynchronously via a goroutine.
func Middleware(cfg Config) gin.HandlerFunc {
	if cfg.APIURL == "" {
		cfg.APIURL = "https://api.immutablelog.com"
	}
	if cfg.APIKey == "" {
		cfg.APIKey = os.Getenv("IMTBL_API_KEY")
	}
	if cfg.ServiceName == "" {
		cfg.ServiceName = os.Getenv("IMTBL_SERVICE_NAME")
	}
	if cfg.Env == "" {
		cfg.Env = os.Getenv("IMTBL_ENV")
	}

	skipSet := make(map[string]struct{}, len(cfg.SkipPaths))
	for _, p := range cfg.SkipPaths {
		skipSet[p] = struct{}{}
	}

	return func(c *gin.Context) {
		if _, skip := skipSet[c.Request.URL.Path]; skip {
			c.Next()
			return
		}

		startedAt := time.Now()

		var bodyBytes []byte
		if c.Request.Body != nil {
			bodyBytes, _ = io.ReadAll(c.Request.Body)
			c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
		}

		c.Next() // execute all handlers

		elapsed := time.Since(startedAt)
		status := c.Writer.Status()

		// Custom event name set by a handler via c.Set(ginEventNameKey, "...")
		eventName, _ := c.Get(ginEventNameKey)
		go emit(c.Request, status, elapsed, bodyBytes, eventName, cfg)
	}
}

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, customName any, cfg Config) {

	requestID := r.Header.Get("X-Request-Id")
	if requestID == "" {
		requestID = fmt.Sprintf("%d", time.Now().UnixNano())
	}

	eventName, _ := customName.(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, _ := json.Marshal(p)

	evt := eventBody{
		Payload: string(payloadJSON),
		Meta: eventMeta{
			Type:      kind,
			EventName: eventName,
			Service:   cfg.ServiceName,
			Env:       cfg.Env,
			RequestID: requestID,
		},
	}
	evtJSON, _ := json.Marshal(evt)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		cfg.APIURL+"/v1/events", bytes.NewBuffer(evtJSON))
	if err != nil {
		return
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+cfg.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)
}

Global registration

go
package main

import (
	"github.com/gin-gonic/gin"
	"github.com/yourorg/yourapp/immutablelog"
)

func main() {
	r := gin.Default()

	// Register globally — applies to all routes
	r.Use(immutablelog.Middleware(immutablelog.Config{
		APIKey:      "iml_live_xxxx",
		ServiceName: "my-api",
		Env:         "production",
		SkipPaths:   []string{"/health", "/metrics", "/ping"},
	}))

	r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
	r.POST("/payments", paymentsHandler)
	r.GET("/users/:id", usersHandler)

	r.Run(":8080")
}

How it works

1. c.Next()

c.Next() executes the full handler chain. Code after c.Next() in the middleware runs with the response already prepared — c.Writer.Status() has the correct code.

2. go emit()

The event is fired in a separate goroutine. Gin already sent the headers to the client — the audit adds no perceptible latency to the response.

3. c.Get() / c.Set()

Gin stores values in the request context via c.Set(). The middleware reads imtbl.eventName after c.Next(), when the handler has already set the value.

Custom event

Use c.Set("imtbl.eventName", "...") in the handler to define a semantic name. The middleware reads the value after c.Next().

go
func paymentsHandler(c *gin.Context) {
	// Set custom event name — middleware reads after c.Next() returns
	c.Set("imtbl.eventName", "payment.created")

	var req PaymentRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"error": err.Error()})
		return
	}

	// ... process payment ...
	c.JSON(201, gin.H{"ok": true, "payment_id": "pay_123"})
}

Route groups

Apply the middleware to a route group to audit only specific endpoints, without affecting public routes.

go
// Apply middleware only to a specific route group
api := r.Group("/api/v1")
api.Use(immutablelog.Middleware(immutablelog.Config{
	APIKey:      os.Getenv("IMTBL_API_KEY"),
	ServiceName: "api-v1",
	Env:         os.Getenv("APP_ENV"),
}))

api.POST("/payments", paymentsHandler)
api.GET("/users", usersHandler)

// Routes outside the group are NOT audited
r.GET("/health", healthHandler)

Abort & authentication

When a middleware calls c.Abort(), the audit still captures the status. Register audit BEFORE auth to capture unauthorized attempts.

go
// When a handler calls c.Abort(), the audit middleware still runs
// because c.Next() already returned with the aborted status.

func AuthMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		if c.GetHeader("Authorization") == "" {
			c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
			return // abort — audit still sees status 401
		}
		c.Next()
	}
}

// Register audit BEFORE auth to capture unauthorized attempts
r.Use(immutablelog.Middleware(cfg)) // runs first, calls c.Next()
r.Use(AuthMiddleware())             // may abort — audit captures 401

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