Gin — ImmutableLog
Middleware para o framework Gin. Usa c.Next() para capturar status após todos os handlers, goroutine fire-and-forget para não bloquear a resposta, e c.Set() para eventos customizados.
Código do middleware
Crie um pacote immutablelog no seu projeto. Retorna gin.HandlerFunc e pode ser registrado com r.Use() globalmente ou em grupos de rotas.
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)
}Registro global
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")
}Como funciona
1. c.Next()
c.Next() executa toda a cadeia de handlers. O código após c.Next() no middleware roda com a resposta já preparada — c.Writer.Status() tem o código correto.
2. go emit()
O evento é disparado em uma goroutine separada. Gin já enviou os headers ao cliente — o audit não acrescenta latência perceptível à resposta.
3. c.Get() / c.Set()
Gin armazena valores no contexto da request via c.Set(). O middleware lê imtbl.eventName após c.Next(), quando o handler já definiu o valor.
Evento customizado
Use c.Set("imtbl.eventName", "...") no handler para definir um nome semântico. O middleware lê o valor depois de 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"})
}Trilha imutável (immutable_trail)
A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Defina via c.Set("imtbl.trail", ...) no handler ou propague entre serviços com o header X-Imtbl-Trail. O valor passa por sanitizeTrail antes do envio.
func runFlowHandler(c *gin.Context) {
c.Set("imtbl.trail", "flow-"+c.Param("id"))
// ... business logic ...
c.JSON(200, gin.H{"ok": true})
}A trilha não pode ser vazia, exceder 256 caracteres ou conter ":" — violações retornam 400 invalid_immutable_trail. O sanitizeTrail corrige o valor antes do envio.
Route groups
Aplique o middleware em um grupo de rotas para auditar apenas endpoints específicos, sem afetar rotas públicas.
// 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 e autenticação
Quando um middleware chama c.Abort(), o audit ainda captura o status. Registre o audit ANTES do auth para capturar tentativas não autorizadas.
// 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 401Esta documentação reflete o comportamento atual da integração. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.
