A lightweight, type-safe event tracking library with runtime validation, background batching, and retries. Works in Node.js, browsers, and any modern JavaScript runtime.
Effect powers validation and delivery internally. App code uses a plain TypeScript API — no Effect knowledge required. Use trashlytics/effect when your app is already Effect-based.
import { Schema } from "effect"
import { createTracker, event, httpSink } from "trashlytics"
const events = {
signup: event("user.signup", {
userId: Schema.String,
plan: Schema.Literals(["free", "pro"])
}),
purchase: event("purchase.completed", {
orderId: Schema.String,
total: Schema.Number
}),
pageview: event("page.viewed")
}
const tracker = createTracker({
events,
sink: httpSink("/api/events"),
batchSize: 20,
flushInterval: 5000,
retry: { attempts: 3, delay: 250, factor: 2, jitter: true },
context: () => ({ sessionId: getSessionId() }),
onError(error, batch) {
console.warn("event delivery failed", error, batch)
}
})
tracker.track("signup", { userId: "u_123", plan: "free" })
tracker.track("pageview") // payload-less event
await tracker.close() // flush everything and release resourcestrack is fire-and-forget: it validates, stamps, and queues the event, and never blocks on the network. Delivery happens on a background fiber — when the batch size is reached, on the flush interval, on flush(), and on close().
Event payloads can be validated with Effect schemas or any Standard Schema v1 validator (zod, valibot, arktype, ...):
import { z } from "zod"
const events = {
signup: event("user.signup", z.object({
userId: z.string(),
plan: z.enum(["free", "pro"])
}))
}Payload types are inferred from the schema either way.
The sink receives a discriminated union based on your event map.
const tracker = createTracker({
events,
sink: async (batch) => {
for (const item of batch) {
if (item.key === "signup") {
item.payload.plan // "free" | "pro"
}
if (item.key === "purchase") {
item.payload.total // number
}
}
}
})Each event includes the local typed key, the external event name, a timestamp, and merged metadata:
type Event = {
key: "signup"
name: "user.signup"
payload: { userId: string; plan: "free" | "pro" }
timestamp: number
meta?: Record<string, unknown>
}meta is the tracker-level context (static object or lazy function) merged with per-event metadata:
tracker.track("signup", payload, { meta: { experiment: "b" } })A sink is just a function receiving batches. It can return void, a Promise, or an Effect.
httpSink(url, options?)— POSTs JSON batches withfetch.keepalivedefaults totrueso requests survive page unloads.beaconSink(url)— delivers withnavigator.sendBeacon(browsers).consoleSink()— logs batches.- Any custom function:
sink: async (batch) => { ... }.
Failed deliveries are retried per the retry policy; batches that still fail are reported to onError and dropped.
Use trackNow when the caller needs to wait for delivery (it bypasses the queue):
await tracker.trackNow("purchase", { orderId: "o_123", total: 49 })close() stops background delivery, flushes all remaining events, and releases resources. Trackers also implement AsyncDisposable:
await using tracker = createTracker({ events, sink })
// tracker.close() runs automatically at scope exitIn browsers, the tracker automatically flushes when the page is hidden or unloading (visibilitychange/pagehide). Disable with flushOnHide: false.
All failures are tagged: EventValidationError, UnknownEventError, TrackerClosedError, QueueFullError, SinkError. onError observes every delivery failure (from background flushing, flush, and trackNow) plus validation failures from fire-and-forget track; trackNow and flush additionally reject with the failure so callers can react.
import { Effect, Schema } from "effect"
import * as Tracker from "trashlytics/effect"
const events = {
signup: Tracker.event("user.signup", {
userId: Schema.String,
plan: Schema.Literals(["free", "pro"])
})
}
const program = Effect.gen(function* () {
const tracker = yield* Tracker.make({
events,
sink: Tracker.httpSink("/api/events"),
retry: { attempts: 3 }
})
yield* tracker.track("signup", { userId: "u_123", plan: "free" })
yield* tracker.flush
}).pipe(Effect.scoped)Tracker.make is scoped: closing the scope interrupts the background delivery fiber and flushes all remaining events. Errors are fully typed in the failure channel (TrackError for track, the sink's error type for flush/trackNow).
To share a tracker across your app, wrap it in a Layer:
import { Context, Layer } from "effect"
class Analytics extends Context.Service<Analytics, Tracker.Tracker<typeof events>>()("Analytics") {}
const AnalyticsLayer = Layer.effect(Analytics, Tracker.make({ events, sink }))| Option | Default | Description |
|---|---|---|
batchSize |
20 |
Max events per sink call. Reaching it triggers background delivery. |
flushInterval |
5000 |
Auto-flush interval in ms. 0 disables interval flushing. |
deliveryTimeout |
30000 |
Max ms per sink call before the attempt is failed (and retried per retry). Keeps flush/close bounded even if a sink never settles. 0 disables. |
maxQueueSize |
1000 |
Max queued events; beyond it new events are rejected. |
retry |
none | Retry count or { attempts, delay, factor, jitter }. |
context |
none | Static or lazy metadata merged into every event's meta. |
onError |
none | Observes validation and delivery failures. |
flushOnHide |
true |
(Root entry only) flush on page hide/unload in browsers. |
MIT