Configuration
Every option for producers, the Hub, the
collector route and destinations,
with its default and when to change it. Option names and defaults are those of the Next.js
implementation, @eventrail/nextjs; other implementations follow the
same model.
Producer options
Browser producer
| Option | Default | What it does |
|---|---|---|
collectorLocation | Required | The route the collector listens on, e.g. "/api/eventrail". |
storeSessionId | Required | The storage policy: "enabled", "disabled", "optIn" or "optOut". |
producerName | null | Labels the stream in the record's producerName, e.g. "browser". |
properties | None | A function called once per event. Its result is merged into producerProperties; the track() call wins on a key clash. |
sessionTTL | 3600 (one hour) | Lifetime of the session identifier, in seconds. |
sessionRenewal | "sliding" | "sliding" extends the lifetime on every event sent; "absolute" keeps the original expiry. |
consentMaxAge | 180 days | Lifetime of the consent cookie, in seconds. After it expires, the visitor is asked again. |
queueEventsUntilConsentDecisionWasTaken | false | Holds events in memory until the visitor answers the consent question, then sends them. |
onError | Logs with console.error | Receives every failure behind a track() call. See Error handling. |
Next.js · lib/eventrail-client.ts
"use client";
import { createBrowserProducer } from "@eventrail/nextjs/client";
export const producer = createBrowserProducer({
collectorLocation: "/api/eventrail",
storeSessionId: "optIn",
producerName: "browser",
properties: () => ({ appVersion: process.env.NEXT_PUBLIC_APP_VERSION ?? null }),
sessionTTL: 60 * 60 * 24 * 30,
});
export const { track } = producer;collectorLocation: keep it on your app's own origin. The producer only sends cookies to its own origin, and the collector sends no CORS headers.storeSessionId: pick the policy that matches your consent setup. See Consent.producerName: the browser claims this value and the collector cannot verify it, so treat it as a label, not as identity. See Trust.properties: use it for context every event should carry, such as the app version or an A/B bucket, instead of repeating it at every call site. If it throws, the event is dropped and the error goes toonError.sessionTTL: raise it to recognise returning visitors over days or weeks; shorter sessions can always be derived later. Safari limits cookies set from JavaScript to seven days, so a longer TTL is effectively shorter there.sessionRenewal: switch to"absolute"when a session should have a fixed maximum length, however active the visitor is.queueEventsUntilConsentDecisionWasTaken: turn it on with a blocking consent banner, so the events fired behind the banner carry the decision andsessionIdthe answer allows. Held events keep the time, location and properties of theirtrack()call. The queue is in memory only: a page load before the answer discards it, so leave this off with a banner that doesn't block. At most 100 events are held; further ones go toonError.
Server producer
| Option | Default | What it does |
|---|---|---|
producerName | null | Labels the stream in the record's producerName, e.g. "server". |
properties | None | Same as on the browser producer, except that a failure goes to the Hub's onError. |
Both are optional. Unlike the browser's, this producerName is trusted: it comes from your server
configuration, not from the network. The server producer manages no cookies and no consent; the
Hub reads both from the current request.
Hub options
| Option | Default | What it does |
|---|---|---|
destinations | Required | Where every record is written. See Destinations. |
hubProperties | None | A function, sync or async, called once per event. Its result is merged into the record's hubProperties, overriding the collected values on a key clash. |
collect | All on | Which request values the Hub adds to hubProperties itself: userAgent, forwardedFor and acceptLanguage. |
onError | Logs with console.error | Receives every server-side failure. See Error handling. |
Next.js · lib/eventrail-hub.ts
import "server-only";
import { createHub } from "@eventrail/nextjs/server";
import { createConsoleDestination } from "@eventrail/nextjs/destinations/console";
export const hub = createHub({
destinations: [createConsoleDestination()],
hubProperties: () => ({ region: process.env.VERCEL_REGION ?? null }),
collect: { forwardedFor: false },
});destinations: order carries no meaning. Every destination receives every record, and one failing does not affect the others.hubProperties: this is where your server adds what the browser doesn't know or can't be trusted with, such as the signed-in user, a tenant or a plan. See Server-resolved user IDs. If it throws, or returns something outside the size limits, the event is dropped and the error goes toonError. In Next.js, readheaders()andcookies()before the function's firstawait: a server event can be handed over just as its request finishes, after which the request data is gone.collect: turn off what you don't want to store.forwardedForholds IP addresses, which are personal data in many jurisdictions.forwardedForis theX-Forwarded-Forheader exactly as it arrived. The Hub doesn't work out the visitor's address from it, because which entry to trust depends on how many proxies sit in front of your app; that is left to whoever reads the data. See Relating events without a sessionId. A header longer than 4,096 characters (LIMITS.maxHeaderValueLength) is clipped from the start, which keeps the entries your own proxies appended.
Collector options
| Option | Default | What it does |
|---|---|---|
allowedOrigins | Required | Origins accepted when a request carries an Origin header, e.g. ["https://example.com"]. |
Include your app's own origin, for every environment you deploy to. Requests without an Origin
header, such as those from native apps and servers, are allowed. The check protects against CSRF;
it is not authentication, and there is no rate limiting built in. See
Collector. To accept events only from signed-in users, see
Authenticated users only.
Error handling
Tracking never fails the code that calls it. EventRail throws only to a caller that asked for something and can act on the answer:
setConsent()throws when the browser refuses the consent cookie.createCollector()throws when anallowedOriginsentry is not a valid origin.
Every other failure drops the event and goes to an onError handler: an invalid track() call, a
properties() or hubProperties() function that throws, a failed send, a destination that
cannot write. None of them surfaces as an unhandled promise rejection.
- Browser: the browser producer's
onErrorreceives the failures behind itstrack()calls. - Server: the Hub's
onErrorreceives everything else: from the server producer, the collector and destinations. When a collector request fails on the server's side, the collector also answers it with500.
Both default to logging with console.error. Each failure arrives once, as an EventRailError,
with the underlying error, such as a database driver's, on its cause. Point onError at your
error tracker to hear about failures in production:
Next.js · lib/eventrail-hub.ts
import "server-only";
import { createHub } from "@eventrail/nextjs/server";
import { createConsoleDestination } from "@eventrail/nextjs/destinations/console";
import { reportError } from "@/lib/monitoring"; // your code
export const hub = createHub({
destinations: [createConsoleDestination()],
onError: (error) => reportError(error, { source: "eventrail" }),
});The browser producer takes the same option. If onError itself throws, that error is logged with
console.error and goes no further.
Destinations
EventRail ships these destinations. Each is its own entry point, so you only bundle what you use.
| Destination | Import from | Needs |
|---|---|---|
| Console | @eventrail/nextjs/destinations/console | Nothing |
| File (NDJSON) | @eventrail/nextjs/destinations/file | A persistent filesystem |
| Postgres | @eventrail/nextjs/destinations/postgres | npm install pg |
| BigQuery | @eventrail/nextjs/destinations/bigquery | npm install @google-cloud/bigquery, an existing dataset |
Postgres and BigQuery write to a versioned table, <table>_v<specVersion> (eventrail_events_v1
by default), with snake_case columns. Both create that table when it doesn't exist. To write
anywhere else, build your own.
Console
Prints every record as formatted JSON. Nothing to provision, which makes it the one to start with.
| Option | Default | What it does |
|---|---|---|
label | "[EventRail]" | Prefix on every line. |
log | console.log | Receives the formatted text and the record. |
It is useful in production too: point log at your existing logger, and records go wherever your
logs already go.
File
Appends every record as one line of JSON (NDJSON). jq, DuckDB and BigQuery load that format
directly.
| Option | Default | What it does |
|---|---|---|
path | Required | File to append to. It is created, along with its directory, if missing. |
Use it for local development and for self-hosted apps with a persistent disk. Serverless platforms have no lasting filesystem to append to.
Postgres
Inserts one row per record. Properties are stored as jsonb, so the column list stays fixed
however many properties you record.
| Option | Default | What it does |
|---|---|---|
pool | Required | An existing pg.Pool to share, or pool settings (connectionString, max, …) for a pool the destination creates and owns. |
table | "eventrail_events" | Base table name. Records go to <table>_v<specVersion>. |
schema | The connection's search path | Schema holding the table. |
createTable | true | Runs CREATE TABLE IF NOT EXISTS before the first write to each table. The connection's role needs DDL privilege on the schema. |
Next.js · lib/eventrail-hub.ts
import "server-only";
import { createHub } from "@eventrail/nextjs/server";
import { createPostgresDestination } from "@eventrail/nextjs/destinations/postgres";
import { pool } from "@/lib/db";
export const hub = createHub({
destinations: [createPostgresDestination({ pool, schema: "analytics" })],
});- Share your app's pool where you have one. On a serverless platform every instance holds its own connections, and each extra pool counts against the database's connection limit.
- Create the table yourself when the runtime's database role should not hold DDL permission,
or when a migration tool owns the schema.
postgresTableDDL(table, specVersion, schema)returns the exact statementcreateTablewould run. Then passcreateTable: false; the role only needsINSERT. - On shutdown of a long-running server,
close()ends the pool if the destination created it. A pool you passed in is left to you.
BigQuery
Streams records into BigQuery, one insert per record by default.
| Option | Default | What it does |
|---|---|---|
projectId | Required | Project the dataset lives in. |
datasetId | Required | Dataset holding the table. It must already exist. |
table | "eventrail_events" | Base table name. Records go to <table>_v<specVersion>, as with Postgres. |
client | Application Default Credentials | An existing BigQuery client, or options to build one with. |
createTable | true | Creates the table, partitioned by day on received_at, when it does not exist. |
buffer | Off | Groups rows into fewer inserts: { maxRows: 500, maxWaitMs: 1000 } by default once set. |
Next.js · lib/eventrail-hub.ts
import "server-only";
import { createHub } from "@eventrail/nextjs/server";
import { createBigQueryDestination } from "@eventrail/nextjs/destinations/bigquery";
export const hub = createHub({
destinations: [
createBigQueryDestination({
projectId: "my-project",
datasetId: "analytics",
buffer: { maxRows: 500, maxWaitMs: 1000 },
}),
],
});- Permissions:
roles/bigquery.dataEditoron the dataset covers everything the destination does. If the table is provisioned elsewhere, for example with Terraform using the exportedbigQueryTableSchema, passcreateTable: false; the credentials then only needbigquery.tables.updateData. - Buffering: leave it off on serverless platforms, where the process may not live long enough
to flush. With buffering on, rows still waiting in the buffer (at most
maxWaitMsworth) are lost if the process crashes. On a normal shutdown,flush()inserts them. In Next.js,next startfinishes pending writes before it exits onSIGTERM, buffered rows included, so you only needflush()if you handle shutdown yourself, for example in a custom server.
Your own destination
A destination is an object with a name and an async write(record):
Next.js · lib/memory-destination.ts
import type { Destination, EventRecord } from "@eventrail/nextjs/core";
export const collected: EventRecord[] = [];
export const memoryDestination: Destination = {
name: "memory",
async write(record) {
collected.push(record);
},
};- Don't change the record. It is frozen, so the next destination receives it unchanged.
- Reject to report a failure. The Hub hands the error to its
onError; other destinations are unaffected.nameidentifies your destination in that error. - If you buffer, you own the consequences: when to flush, and how many records a crash can lose. The Hub hands you one record at a time and cannot see your buffer.
An in-memory destination like the one above is also an easy way to assert on events in tests.