Skip to main content

Context Propagation

event-spec uses a 4-level context chain to propagate identity and metadata through your application without passing arguments everywhere. Each level can override the one below it — higher levels win.

The four levels

Non-empty fields override at each level. Attributes maps are merged key-by-key — a higher-level key wins but doesn't wipe out other keys from lower levels.

AnalyticsContext fields

type AnalyticsContext struct {
UserID string
AnonymousID string
Attributes map[string]any
}

Setting the global context

Set device/locale/app metadata once at startup:

analytics.SetGlobalContext(analytics.AnalyticsContext{
Attributes: map[string]any{
"locale": "en-US",
"user_agent": r.UserAgent(),
"app": map[string]any{
"name": "my-app",
"version": "2.1.0",
},
},
})

HTTP middleware (transaction context)

In server applications, inject user identity per-request using WithTransaction:

func AnalyticsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
txCtx := analytics.TransactionContext{
UserID: extractUserID(r),
AnonymousID: extractSessionID(r),
Attributes: map[string]any{
"request_id": r.Header.Get("X-Request-ID"),
"user_agent": r.UserAgent(),
},
}
ctx := analytics.WithAnalyticsContext(r.Context(), txCtx)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Downstream handlers can call client.Track(ctx, ...) without knowing the user ID — it's already in ctx.

MessageContext

When an event is dispatched, AnalyticsContext.Attributes are promoted into a typed MessageContext struct. Well-known keys map to specific fields:

Attribute keyMessageContext field
user_agentUserAgent
localeLocale
ip_addressIP
appApp (name, version, build)
deviceDevice (manufacturer, model, type)
osOS (name, version)
screenScreen (width, height, density)
campaignCampaign (source, medium, name)
unknown keysExtra map

Identify and context separation

IdentifyMessage keeps traits and context in separate buckets — they are never mixed:

FieldWhat it isWhere it comes from
traitsWho the user is — email, name, plan, custom propertiesThe traits argument you pass directly
contextThe environment the call was made from — device, locale, user agent, IPAnalyticsContext.Attributes from the context chain

buildMessageContext is called internally on every dispatch (track, identify, group, page, alias) to promote well-known attribute keys (user_agent, locale, ip_address, app, device, os, etc.) into typed MessageContext fields. Unknown keys land in extra and still flow through to the provider.

Calling identify

err := client.Identify(ctx, "user-123", map[string]any{
"email": "alice@example.com",
"name": "Alice",
"plan": "pro",
"created_at": "2026-01-15T10:00:00Z",
})

The resulting message sent to every provider separates the two buckets cleanly:

IdentifyMessage {
userId: "user-123"
anonymousId: "anon-abc" // always from the context chain, never passed directly
traits: { // your traits argument (after hooks)
email: "alice@example.com"
plan: "pro"
}
context: { // built from AnalyticsContext.Attributes
locale: "en-US"
userAgent: "Mozilla/5.0 ..."
app: { name: "web-app", version: "2.1.0" }
}
}

Identity resolution

The userId positional argument is the canonical identity, but the merged context chain takes precedence: if a non-empty UserID is already present in the context (e.g. injected by AnalyticsMiddleware in Go or withTransaction in TypeScript), it wins. This means you can call identify from inside a middleware-wrapped handler without re-extracting the user ID:

// AnalyticsMiddleware has already set UserID in ctx — pass "" to let context win
client.Identify(ctx, "", map[string]any{"plan": "enterprise"})

Traits flow through the full hook chain (Before → providers → After/Error/Finally), so validation, sampling, and custom hooks apply exactly as they do for track events.