Concepts
EventRail tracks events inside your own application. Events are fired where they happen, your own server enriches them with what only it can know, and they are written to storage you own. EventRail has no fixed idea of what your app is: it has no built-in e-commerce columns and no preset funnels. It only records events, in one consistent shape.
This page explains the parts of EventRail and why they are designed the way they are. Every implementation follows these concepts.
The path of an event
Every event travels the same path:
The Producer turns a track() call into an event. The Hub runs in your
server: it enriches the event and builds the final record. Destinations write that
record to Postgres, BigQuery, a file, or the console while you're getting started. A Producer never
writes to a Destination, and a Destination never sees a Producer. Everything passes through the
Hub.
Producer
The Producer lives next to your application code. It provides the one call you use everywhere:
track("signup_completed", { plan: "pro" });The Producer sets the values only the caller's context knows:
- a unique
id - the
location: the current URL in a browser, the current path in an app, the referer on a server - the
occurredAtandsentAttimestamps - its own
producerName - the properties you passed, optionally extended by a
properties()function you configure
track() has the same shape in every Producer. Moving an event from browser code to server code
changes the import and nothing else. In Next.js, that means moving it from a Client Component to a
server action.
Producers are either local or remote, and this is the most important distinction:
- A local producer runs in the same process as the Hub and calls it directly. This is typically your server.
- A remote producer runs outside your trusted runtime and reaches the Hub over HTTP, through a Collector. This is typically the browser, and later native apps.
The Hub stamps every record with producerType: "local" | "remote" based on how the event arrived.
The Hub can observe this itself, so it doesn't have to trust the producer's word for it.
In browsers and apps, the Producer also manages the visitor's state. It creates, renews and clears the session identifier, and it stores the consent value. This state lives on the client because EventRail tracks user behaviour; it is not a server-side tracing tool.
Hub
The Hub is the core of EventRail, and it runs inside your application, not beside it. For each event it:
- validates the event,
- reads the visitor's identifier and consent from the request,
- adds
receivedAtand yourhubProperties, - assembles the final EventRecord,
- hands the record to each Destination.
hubProperties is where your server adds what the browser doesn't know or can't be trusted with:
the logged-in user from your session, a tenant, a plan, a feature flag. The implementation can fill
in some standard values itself, such as the user agent, the language and the X-Forwarded-For
header. You configure which of those it collects in the Hub options.
Why is there no standalone Hub server? Because running inside your application is the point. Only there can the Hub read your session cookies, check who the user is, and look up data in your own backend. A separate service would lose exactly that access.
Collector
A Collector is the HTTP route that lets remote producers reach the Hub. It is mounted on your
app's own origin, for example /api/eventrail. It is the only part of EventRail that faces the
network, so it is where the trust boundary sits. Everything arriving through it is stamped
remote.
Each request carries exactly one event, sent as application/json. The collector checks the
content type and compares the Origin header against
allowedOrigins before it parses the body. It answers
202 Accepted once the record is assembled. You can mount more than one collector on the same
Hub, for example a separate route for native apps with its own origin rules.
The origin check protects against CSRF; it is not authentication. It stops another website from making your visitor's browser send events with your visitor's cookies attached. It does not prove who sent an event:
- any non-browser client can set
Originto anything, and - requests with no
Originheader at all, such as those from native apps and servers, are allowed.
Nothing at the collector authenticates a caller. That is why EventRail expresses trust per field instead. To accept events only from signed-in users, check the session in front of the collector: see Authenticated users only.
Destination
A Destination writes records to storage you own. It only writes, it never changes the record, and
it fails on its own. If BigQuery is down, Postgres still gets its row, and the browser still gets
its 202.
The Hub hands each Destination one record at a time. Neither Producers nor the Hub batch events. If a Destination benefits from grouped writes, it buffers internally and documents when it flushes and how many events could be lost. The component that chooses to buffer owns the consequences.
The destinations that ship, and how to write your own: Destinations.
The EventRecord
The EventRecord is what every component exists to produce. It is a single row with the same shape, whichever producer it came from.
| Field | Set by | Trust |
|---|---|---|
id, name, location, occurredAt, sentAt, producerProperties | Producer / track() | Claimed |
producerName | Producer | Claimed when remote, trusted when local |
sessionId, consent, producerType | Hub | Resolved |
receivedAt, specVersion, hubProperties | Hub | Trusted |
Two details are worth knowing up front:
receivedAtis the only timestamp that's safe to sort on. Client clocks are wrong often enough that EventRail records them but never drops an event because they disagree.- All field names are camelCase, because most events are fired from JavaScript. The Postgres and
BigQuery destinations write them to snake_case columns, such as
session_id.
Every field, what it holds and example records: EventRecord.
Trust: claimed, resolved, trusted
Most analytics tools accept a user_id in the event payload and treat it as fact. Anyone with a
browser console can fake it. EventRail instead groups every field by who wrote it:
- Claimed fields are whatever a producer said. From a browser, a forged request could set any of them. Use them for analysis, but never for authorisation or billing without checking them against something else.
- Resolved fields are worked out by the Hub from the request itself: identifier and consent from cookies or headers, producer type from how the event arrived. They are never taken from the request body. If a client sends them, they are ignored.
- Trusted fields are produced by your server. "Trusted" means you know who computed the value, not that the value is true: your server can still derive something from an untrusted header.
This is also why EventRail has no dedicated userId field. Your server resolves the user and puts
it in hubProperties, in whatever shape your identity model uses. What the browser claimed and
what your server resolved end up in separate columns, so you always know which is which. See
Server-resolved user IDs.
One subtlety: producerName changes group depending on where it came from. From a local producer
it is server configuration. From a remote producer it is just what the client said. Filter on
producerType being "local" when that difference matters.
Identity: one identifier
EventRail stores exactly one thing about a visitor: sessionId.
- Browser: it lives in the
EventRailSessionIDcookie. The Producer manages it and renews it on activity. - App: the app sends the same value in the
X-EventRail-Session-Idheader. - Server: the Hub reads it from the current request when there is one. For a cron job or
webhook it is
null.
The Hub only reads the identifier; it never creates one. Two Producer options control it:
sessionTTLsets how long the identifier lives (one hour by default).sessionRenewaldecides whether activity extends that lifetime ("sliding", the default) or the original expiry stands ("absolute").
Why not both an anonymous ID and a session ID? Because one can be derived from the other. With a
long-lived visitor identifier, short sessions are just that identifier plus timestamps. You can
split them however you like later: after 30 minutes of inactivity, at midnight, when the campaign
changes. If you want long-running tracking, set sessionTTL long. Storing a second identifier
would double what you keep about a visitor without telling you anything you couldn't compute.
A null sessionId is normal. When consent doesn't allow storage, events still flow, just without
an identifier, and your metrics should expect that.
One known limitation: Safari limits cookies set from JavaScript to seven days, so a long
sessionTTL is effectively shorter there.
Consent
EventRail uses a single yes/no consent. It covers storing the identifier and, depending on how your banner is worded, collecting personal data as well. EventRail deliberately doesn't split cookie consent from data consent.
The Producer lets you read and set consent. It stores the value in the EventRailConsent cookie as
accepted or denied; no cookie means unset. Like the identifier, the Hub reads consent from
the request and records it on every row. That means you can always separate data collected with
consent from data collected without it. When consent changes, the Producer immediately creates or
deletes the identifier as the storage policy allows.
The storeSessionId setting controls how consent affects storage:
| Policy | unset | accepted | denied |
|---|---|---|---|
enabled | Store | Store | Store |
disabled | No store | No store | No store |
optIn | No store | Store | No store |
optOut | Store | Store | No store |
Which policy fits depends on where you operate. How to wire consent to your banner is on Consent. Two things are out of scope for now: consent that varies by visitor country, and per-purpose consent (for example measurement vs. marketing).
Topologies
| Topology | Producer | How it reaches the Hub | Where identifier and consent come from |
|---|---|---|---|
| Browser | BrowserProducer (remote) | POST to your collector route | Cookies |
| Server | ServerProducer (local) | In-process call | The current request, if there is one |
| Native app | AppProducer (remote, planned) | POST to a collector route | Headers |
Most applications run browser and server together against the same Hub. In Next.js, for example, a signup fired from a Client Component and a plan change fired from a server action land as rows with the same shape and the same identifier.
Do you support server events from a microservice landscape? Not as a focus. EventRail tracks user behaviour. We believe most of it happens on the client, and most of what happens on the server is visible to your backend-for-frontend, where a local producer can record it. Events that arise deep in a service mesh are a small share of user behaviour, and tracing tools are the better fit for following requests between services.
What EventRail deliberately doesn't do
The open-source package's job ends when a well-formed row lands in your storage. Dashboards, query layers, sessionisation and identity stitching happen downstream. EventRail also doesn't batch events, run as a separate server, or aim to trace requests between services.