Hooks
Hooks are the middleware layer for the event pipeline. They execute before and after provider dispatch, enabling cross-cutting concerns like schema validation, sampling, consent checking, PII stripping, and logging — without modifying provider code.
Lifecycle
Before runs once and gates all providers. After, Error, and Finally fire once per provider result. The registration order is api → client → provider for Before and reversed for After/Error/Finally.
The Hook interface
- Go
- TypeScript
- Kotlin
type Hook interface {
Before(ctx context.Context, hc HookContext, hints HookHints) (*EventEnvelope, error)
After(ctx context.Context, hc HookContext, result HookResult, hints HookHints) error
Error(ctx context.Context, hc HookContext, err error, hints HookHints)
Finally(ctx context.Context, hc HookContext, result HookResult, hints HookHints)
}
Use hooks.UnimplementedHook as a base and override only the stages you need:
type MyHook struct {
hooks.UnimplementedHook
}
func (h *MyHook) Before(ctx context.Context, hc hooks.HookContext, hints hooks.HookHints) (*hooks.EventEnvelope, error) {
// Inspect or mutate the event before dispatch
if hc.EventName == "Product Viewed" {
hc.Envelope.Properties["hook_applied"] = true
}
return hc.Envelope, nil
}
interface Hook {
// Return a new EventEnvelope to replace the event, null to pass through, throw to drop.
before(hc: HookContext, hints?: HookHints): Promise<EventEnvelope | null>;
after(hc: HookContext, result: HookResult, hints?: HookHints): Promise<void>;
error(hc: HookContext, err: Error, hints?: HookHints): void;
finally(hc: HookContext, result: HookResult, hints?: HookHints): void;
}
Extend UnimplementedHook and override only the stages you need:
class MyHook extends UnimplementedHook {
async before(hc: HookContext, hints?: HookHints): Promise<EventEnvelope | null> {
// Inspect or mutate the event before dispatch
if (hc.eventName === 'Product Viewed') {
return { ...hc.message as EventEnvelope, properties: { ...(hc.message as EventEnvelope).properties, hook_applied: true } };
}
return null; // pass through unchanged
}
}
interface Hook {
// Return a non-null EventEnvelope to replace the event; throw to cancel.
suspend fun before(hc: HookContext, hints: HookHints = emptyMap()): EventEnvelope? = null
suspend fun after(hc: HookContext, result: HookResult, hints: HookHints = emptyMap()) {}
fun error(hc: HookContext, err: Throwable, hints: HookHints = emptyMap()) {}
fun finally(hc: HookContext, result: HookResult, hints: HookHints = emptyMap()) {}
}
Extend UnimplementedHook and override only the stages you need:
class MyHook : UnimplementedHook() {
override suspend fun before(hc: HookContext, hints: HookHints): EventEnvelope? {
// Inspect or mutate the event before dispatch
if (hc.eventName == "Product Viewed") {
return EventEnvelope(
eventName = hc.eventName,
properties = hc.message.let { (it as? EventEnvelope)?.properties ?: emptyMap() } + mapOf("hook_applied" to true),
context = hc.context,
)
}
return null // pass through unchanged
}
}
HookContext
HookContext is passed to every hook stage:
| Field | Type | Description |
|---|---|---|
Operation | string | "track", "identify", etc. |
EventName | string | The event name |
Context | AnalyticsContext | Merged context chain at time of call |
Envelope | *EventEnvelope | Mutable event (Before only) |
Provider | ProviderMetadata | Which provider this result is for (After/Error/Finally) |
EventEnvelope
EventEnvelope is the mutable event representation passed through Before hooks. Modifying it affects what all providers receive:
- Go
- TypeScript
- Kotlin
type EventEnvelope struct {
Name string
Properties map[string]any
// ... identity fields
}
Returning an error from Before cancels dispatch entirely and marks the event as Dropped.
interface EventEnvelope {
eventName: string;
properties: Record<string, unknown>;
context: AnalyticsContext;
metadata?: Record<string, unknown>;
}
Throwing from before() cancels dispatch entirely and marks the event as Dropped. Return null to pass through unchanged, or return a new EventEnvelope to replace the event for all subsequent hooks and providers.
data class EventEnvelope(
val eventName: String,
val properties: Map<String, Any?>,
val context: AnalyticsContext,
val metadata: Map<String, Any?> = emptyMap(),
)
Throwing from before() cancels dispatch entirely and marks the event as Dropped. Return null to pass through unchanged, or return a new EventEnvelope to replace the event.
Registering hooks
Hooks can be registered at three scopes:
- Go
- TypeScript
- Kotlin
// API-level (applies to all clients)
analytics.AddGlobalHook(myHook)
// Client-level
client := analytics.NewClient(
analytics.WithHooks(validation.New(lookup), sampling.New(samplingHook)),
)
// Provider-level (from Provider.Hooks())
// API-level (applies to all clients)
addGlobalHooks(myHook);
// Client-level
const client = new Client({
hooks: [new ValidationHook(lookup), new SamplingHook(samplingHook)],
});
// Provider-level (from provider.hooks())
// API-level (applies to all clients)
addGlobalHooks(myHook)
// Client-level
val client = Client(ClientOptions(
hooks = listOf(ValidationHook(validator), SamplingHook(lookup)),
))
// Provider-level (from provider.hooks())
Built-in hooks
| Hook | Package | Status |
|---|---|---|
| Schema validation | hooks/validation | ✅ Available |
| Sampling | hooks/sampling | ✅ Available |
| Structured logging | hooks/logging | ❌ Planned |
| OpenTelemetry spans | hooks/otel | ❌ Planned |
Writing a custom hook
See Hooks — Custom.