Skip to main content
ImmutableLog logo
Back
GoFiber

Fiber — ImmutableLog

Middleware for Fiber v2. Uses c.Next() to capture status and errors, c.Locals() for custom events, and goroutine fire-and-forget for zero overhead. Includes correct handling of fasthttp buffer recycling.

Middleware code

Fiber uses fasthttp internally (not net/http). The middleware copies the body before c.Next() because Fiber recycles buffers after each request.

go
package immutablelog

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

	"github.com/gofiber/fiber/v2"
)

const fiberEventNameKey = "imtbl.eventName"
const fiberTrailKey = "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
}

// New returns a fiber.Handler middleware that sends an audit event to ImmutableLog.
// The event is sent asynchronously via a goroutine — never blocks the response.
func New(cfg Config) fiber.Handler {
	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 *fiber.Ctx) error {
		if _, skip := skipSet[c.Path()]; skip {
			return c.Next()
		}

		startedAt := time.Now()

		// Copy body BEFORE c.Next() — Fiber recycles the buffer after the request
		bodyBytes := make([]byte, len(c.Body()))
		copy(bodyBytes, c.Body())

		// Execute all downstream handlers
		err := c.Next()

		elapsed := time.Since(startedAt)
		status := c.Response().StatusCode()

		// Custom event name set by a handler via c.Locals(fiberEventNameKey, "...")
		eventName, _ := c.Locals(fiberEventNameKey).(string)
		// Optional trail set via c.Locals(fiberTrailKey, "flow-123")
		trail, _ := c.Locals(fiberTrailKey).(string)

		go emit(c, status, elapsed, bodyBytes, eventName, trail, err, cfg)

		return err
	}
}

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"`
	ErrorMessage    string `json:"error_message,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(c *fiber.Ctx, status int, elapsed time.Duration,
	body []byte, customName string, customTrail string, handlerErr error, cfg Config) {

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

	eventName := customName
	if eventName == "" {
		eventName = strings.ToLower(c.Method()) + "." +
			strings.ReplaceAll(strings.TrimPrefix(c.Path(), "/"), "/", ".")
	}

	// Trilha imutavel: c.Locals(fiberTrailKey, ...) -> header X-Imtbl-Trail.
	trail := sanitizeTrail(customTrail)
	if trail == "" {
		trail = sanitizeTrail(c.Get("X-Imtbl-Trail"))
	}

	kind := "success"
	if status >= 400 || handlerErr != nil {
		kind = "error"
	} else if status >= 300 {
		kind = "info"
	}

	p := eventPayload{
		Method:    c.Method(),
		Path:      c.Path(),
		Status:    status,
		LatencyMs: elapsed.Milliseconds(),
		ClientIP:  c.IP(),
		UserAgent: string(c.Request().Header.UserAgent()),
	}
	if len(body) > 0 {
		p.RequestBodyHash = sha256Hex(body)
	}
	if handlerErr != nil {
		p.ErrorMessage = handlerErr.Error()
	}

	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 sha256Hex(data []byte) string {
	h := sha256.Sum256(data)
	return fmt.Sprintf("%x", h)
}

Global registration

go
package main

import (
	"github.com/gofiber/fiber/v2"
	"github.com/yourorg/yourapp/immutablelog"
)

func main() {
	app := fiber.New()

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

	app.Get("/health", func(c *fiber.Ctx) error {
		return c.JSON(fiber.Map{"ok": true})
	})
	app.Post("/payments", paymentsHandler)

	app.Listen(":8080")
}

How it works

1. c.Next() + c.Response().StatusCode()

c.Next() executes the full handler chain. After it returns, c.Response().StatusCode() contains the final status code set by the handlers.

2. Error capture via return

In Go/Fiber, errors are returned — not thrown. The middleware captures the error returned by c.Next() and includes it in the event if not nil, along with status 500.

3. c.IP()

Fiber's c.IP() considers X-Forwarded-For automatically when ProxyHeader is configured. Set it via fiber.Config{ProxyHeader: "X-Forwarded-For"} at startup.

Custom event

Use c.Locals() to pass the event name from the handler to the middleware. Locals persists for the full request lifecycle.

go
func paymentsHandler(c *fiber.Ctx) error {
	// Set custom event name via Locals — middleware reads after c.Next()
	c.Locals("imtbl.eventName", "payment.created")

	var req PaymentRequest
	if err := c.BodyParser(&req); err != nil {
		return c.Status(400).JSON(fiber.Map{"error": err.Error()})
	}

	// ... process payment ...
	return c.Status(201).JSON(fiber.Map{"ok": true, "payment_id": "pay_123"})
}

Immutable trail (immutable_trail)

A trail groups related events into a single auditable timeline. Set it via c.Locals("imtbl.trail", ...) in the handler or propagate it across services with the X-Imtbl-Trail header.

go
func runFlowHandler(c *fiber.Ctx) error {
	c.Locals("imtbl.trail", "flow-"+c.Params("id"))
	// ... business logic ...
	return c.JSON(fiber.Map{"ok": true})
}

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

Body recycling

Fiber (fasthttp) reuses request/response buffers for high performance. You must copy the body before c.Next() to ensure the hash is computed over the correct data.

go
// Fiber (fasthttp) recycles request buffers for high performance.
// Always copy the body BEFORE calling c.Next():

bodyBytes := make([]byte, len(c.Body()))
copy(bodyBytes, c.Body())

err := c.Next()
// After this, c.Body() may be empty or contain data from another request.
// Use bodyBytes for hashing — it has a stable copy.

ErrorHandler

Fiber's global ErrorHandler processes errors returned by handlers. The audit middleware captures the error BEFORE ErrorHandler runs — status and error_message are included in the audit event.

go
// Fiber's global ErrorHandler processes errors returned by handlers.
// The audit middleware captures the error returned from c.Next()
// BEFORE ErrorHandler runs — it's included in the event as error_message.

app := fiber.New(fiber.Config{
	ErrorHandler: func(c *fiber.Ctx, err error) error {
		code := fiber.StatusInternalServerError
		if e, ok := err.(*fiber.Error); ok {
			code = e.Code
		}
		return c.Status(code).JSON(fiber.Map{"error": err.Error()})
	},
})

// Handler that returns an error:
app.Get("/fail", func(c *fiber.Ctx) error {
	return fiber.NewError(500, "something went wrong")
	// audit middleware captures: status=500, error_message="something went wrong"
})

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.