Go
Complete integration guide for ImmutableLog in Go. Automatic middleware for net/http, Gin, and Fiber — or direct HTTP client for workers, Kafka consumers, and scheduled jobs. Fire-and-forget goroutines guarantee zero overhead on response latency.
Middlewares
Automatic integration via middleware — every HTTP request is captured without modifying any handler. Click the framework to see the complete code.
Middleware for the standard library. Zero dependencies — compatible with gorilla/mux, chi, and any http.Handler router.
func Middleware(opts Options) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
wrapped := &responseWriter{ResponseWriter: w}
next.ServeHTTP(wrapped, r)
go emit(r, wrapped.Status(), time.Since(startedAt), opts)
})
}
}View full documentation →
gin.HandlerFunc with c.Next(), c.Writer.Status(), and c.Set() for custom events. Supports route groups and auth abort.
func Middleware(cfg Config) gin.HandlerFunc {
return func(c *gin.Context) {
startedAt := time.Now()
c.Next()
elapsed := time.Since(startedAt)
status := c.Writer.Status()
eventName, _ := c.Get("imtbl.eventName")
go emit(c.Request, status, elapsed, eventName, cfg)
}
}View full documentation →
fiber.Handler with fasthttp. Copies the body before c.Next() to avoid buffer recycling. Captures Go errors returned by the chain.
func New(cfg Config) fiber.Handler {
return func(c *fiber.Ctx) error {
body := make([]byte, len(c.Body()))
copy(body, c.Body())
startedAt := time.Now()
err := c.Next()
status := c.Response().StatusCode()
go emit(c, status, time.Since(startedAt), body, err, cfg)
return err
}
}View full documentation →
Direct HTTP client
Use the Client to send events directly to ImmutableLog without depending on a web framework. Ideal for workers, Kafka consumers, cron jobs, and any Go code that needs to record events.
package immutablelog
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
type Client struct {
APIKey string
ServiceName string
Env string
APIURL string
httpClient *http.Client
}
func NewClient(apiKey, serviceName, env string) *Client {
return &Client{
APIKey: apiKey,
ServiceName: serviceName,
Env: env,
APIURL: "https://api.immutablelog.com",
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
type SendOpts struct {
EventName string
Type string // "success" | "info" | "error"
Payload any // will be marshaled to JSON string
Trail string // optional: immutable_trail (groups related events)
}
// sanitizeTrail normaliza o immutable_trail (trim, max 256, sem ':').
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
}
// Send sends a single event. Returns immediately — use SendAsync for fire-and-forget.
func (c *Client) Send(ctx context.Context, opts SendOpts) error {
payloadJSON, err := json.Marshal(opts.Payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
requestID := fmt.Sprintf("%d", time.Now().UnixNano())
trail := sanitizeTrail(opts.Trail)
// Todos os valores de meta precisam ser strings.
meta := map[string]string{
"type": opts.Type,
"event_name": opts.EventName,
"service": c.ServiceName,
"env": c.Env,
"request_id": requestID,
}
if trail != "" {
meta["immutable_trail"] = trail
}
body := map[string]any{
"payload": string(payloadJSON),
"meta": meta,
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.APIURL+"/v1/events", bytes.NewBuffer(bodyJSON))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
// Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
req.Header.Set("Idempotency-Key", opts.EventName+"-"+requestID)
req.Header.Set("Request-Id", requestID)
req.Header.Set("X-Client-TZ", "America/Sao_Paulo")
req.Header.Set("X-Client-Offset-Minutes", "-180")
if trail != "" {
req.Header.Set("X-Imtbl-Trail", trail)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send event: %w", err)
}
defer resp.Body.Close()
return nil
}
// SendAsync sends the event in a goroutine. Never blocks the caller.
func (c *Client) SendAsync(opts SendOpts) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = c.Send(ctx, opts)
}()
}
// Usage:
// client := immutablelog.NewClient(os.Getenv("IMTBL_API_KEY"), "my-service", "production")
//
// client.SendAsync(immutablelog.SendOpts{
// EventName: "user.created",
// Type: "success",
// Payload: map[string]any{"user_id": "usr_123", "plan": "pro"},
// })SendAsync() fires a goroutine and returns immediately. Use Send() when you need the return error. Both respect context.Context for cancellation and timeout.
Retry with exponential backoff
For high-availability production, add retry with exponential backoff. ImmutableLog returns 202 on success — never retry on 429 (monthly limit reached).
// SendWithRetry retries on transient errors (not on 429).
func (c *Client) SendWithRetry(ctx context.Context, opts SendOpts, maxAttempts int) error {
delay := 500 * time.Millisecond
for attempt := 0; attempt < maxAttempts; attempt++ {
err := c.Send(ctx, opts)
if err == nil {
return nil
}
// Do not retry on context cancellation
if ctx.Err() != nil {
return ctx.Err()
}
if attempt+1 < maxAttempts {
time.Sleep(delay)
delay *= 2 // exponential backoff: 500ms → 1s → 2s
}
}
return fmt.Errorf("failed after %d attempts", maxAttempts)
}Batch sending
In high-frequency workers, send multiple events in parallel with sync.WaitGroup. Each event is sent individually — the WaitGroup waits for all to complete.
// SendBatch sends multiple events concurrently using goroutines.
// Uses a WaitGroup to wait for all sends to complete.
func (c *Client) SendBatch(ctx context.Context, events []SendOpts) []error {
errs := make([]error, len(events))
var wg sync.WaitGroup
for i, evt := range events {
wg.Add(1)
go func(idx int, e SendOpts) {
defer wg.Done()
errs[idx] = c.Send(ctx, e)
}(i, evt)
}
wg.Wait()
return errs
}
// Usage:
ctx := context.Background()
errs := client.SendBatch(ctx, []immutablelog.SendOpts{
{EventName: "order.created", Type: "success", Payload: map[string]any{"order_id": "ord_1"}},
{EventName: "payment.captured", Type: "success", Payload: map[string]any{"amount": 9900}},
{EventName: "email.sent", Type: "info", Payload: map[string]any{"template": "welcome"}},
})Sensitive data hashing
Never send raw personal data (email, CPF, IP) to ImmutableLog. Use SHA-256 to generate a deterministic digest — traceable without exposing the original data.
import (
"crypto/sha256"
"fmt"
)
// Hash sensitive data before sending — never log raw PII.
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return fmt.Sprintf("%x", h)
}
// Usage in event payload:
payload := map[string]any{
"user_id": "usr_123",
"email_hash": sha256Hex([]byte("user@example.com")),
"ip_hash": sha256Hex([]byte("192.168.1.1")),
}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 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 API behavior. For questions or advanced integrations, contact the support team.
