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.
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"
const ginTrailKey = "imtbl.trail"
type Config 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
}
// 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")
}
if cfg.ClientTZ == "" {
cfg.ClientTZ = "America/Sao_Paulo"
}
if cfg.ClientOffsetMinutes == 0 {
cfg.ClientOffsetMinutes = -180
}
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)
// Optional trail set by a handler via c.Set(ginTrailKey, "flow-123")
trail, _ := c.Get(ginTrailKey)
go emit(c.Request, status, elapsed, bodyBytes, eventName, trail, 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"`
}
// Todos os valores de meta precisam ser strings (omitempty para campos 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, customName any, customTrail 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, "/"), "/", ".")
}
// Trilha imutavel: c.Set(ginTrailKey, ...) -> header X-Imtbl-Trail.
trail, _ := customTrail.(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, _ := json.Marshal(p)
evt := eventBody{
Payload: string(payloadJSON),
Meta: eventMeta{
Type: kind,
EventName: eventName,
Service: cfg.ServiceName,
Env: cfg.Env,
RequestID: requestID,
ImmutableTrail: trail,
},
}
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)
// 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", cfg.ClientTZ)
req.Header.Set("X-Client-Offset-Minutes", fmt.Sprintf("%d", cfg.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)
}Global registration
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().
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"})
}Immutable trail (immutable_trail)
A trail groups related events into a single auditable timeline. Set it via c.Set("imtbl.trail", ...) in the handler or propagate it across services with the X-Imtbl-Trail header. The value goes through sanitizeTrail before sending.
func runFlowHandler(c *gin.Context) {
c.Set("imtbl.trail", "flow-"+c.Param("id"))
// ... business logic ...
c.JSON(200, gin.H{"ok": true})
}The trail cannot be empty, exceed 256 characters, or contain ":" — violations return 400 invalid_immutable_trail. sanitizeTrail fixes the value before sending.
Route groups
Apply the middleware to a route group to audit only specific endpoints, without affecting public routes.
// 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.
// 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 401meta 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 meta | What it does in the SIEM |
|---|---|
event_name | The 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 login → authentication). |
client_ip | Becomes source.ip with maximum precedence and provenance client_asserted. See the section below. |
immutable_trail | Becomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user). |
type | Event severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.) |
service, env, request_id | Metadata: 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.ipwith 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.
client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IPProvenance 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.
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
{
"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.
