EventRail
Docs menu

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

OptionDefaultWhat it does
collectorLocationRequiredThe route the collector listens on, e.g. "/api/eventrail".
storeSessionIdRequiredThe storage policy: "enabled", "disabled", "optIn" or "optOut".
producerNamenullLabels the stream in the record's producerName, e.g. "browser".
propertiesNoneA function called once per event. Its result is merged into producerProperties; the track() call wins on a key clash.
sessionTTL3600 (one hour)Lifetime of the session identifier, in seconds.
sessionRenewal"sliding""sliding" extends the lifetime on every event sent; "absolute" keeps the original expiry.
consentMaxAge180 daysLifetime of the consent cookie, in seconds. After it expires, the visitor is asked again.
queueEventsUntilConsentDecisionWasTakenfalseHolds events in memory until the visitor answers the consent question, then sends them.
onErrorLogs with console.errorReceives every failure behind a track() call. See Error handling.
lib/eventrail-client.ts

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 to onError.
  • 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 and sessionId the answer allows. Held events keep the time, location and properties of their track() 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 to onError.

Server producer

OptionDefaultWhat it does
producerNamenullLabels the stream in the record's producerName, e.g. "server".
propertiesNoneSame 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

OptionDefaultWhat it does
destinationsRequiredWhere every record is written. See Destinations.
hubPropertiesNoneA 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.
collectAll onWhich request values the Hub adds to hubProperties itself: userAgent, forwardedFor and acceptLanguage.
onErrorLogs with console.errorReceives every server-side failure. See Error handling.
lib/eventrail-hub.ts

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 to onError. In Next.js, read headers() and cookies() before the function's first await: 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. forwardedFor holds IP addresses, which are personal data in many jurisdictions.
  • forwardedFor is the X-Forwarded-For header 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

OptionDefaultWhat it does
allowedOriginsRequiredOrigins 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 an allowedOrigins entry 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 onError receives the failures behind its track() calls.
  • Server: the Hub's onError receives 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 with 500.

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:

lib/eventrail-hub.ts

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.

DestinationImport fromNeeds
Console@eventrail/nextjs/destinations/consoleNothing
File (NDJSON)@eventrail/nextjs/destinations/fileA persistent filesystem
Postgres@eventrail/nextjs/destinations/postgresnpm install pg
BigQuery@eventrail/nextjs/destinations/bigquerynpm 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.

OptionDefaultWhat it does
label"[EventRail]"Prefix on every line.
logconsole.logReceives 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.

OptionDefaultWhat it does
pathRequiredFile 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.

OptionDefaultWhat it does
poolRequiredAn 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>.
schemaThe connection's search pathSchema holding the table.
createTabletrueRuns CREATE TABLE IF NOT EXISTS before the first write to each table. The connection's role needs DDL privilege on the schema.
lib/eventrail-hub.ts

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 statement createTable would run. Then pass createTable: false; the role only needs INSERT.
  • 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.

OptionDefaultWhat it does
projectIdRequiredProject the dataset lives in.
datasetIdRequiredDataset holding the table. It must already exist.
table"eventrail_events"Base table name. Records go to <table>_v<specVersion>, as with Postgres.
clientApplication Default CredentialsAn existing BigQuery client, or options to build one with.
createTabletrueCreates the table, partitioned by day on received_at, when it does not exist.
bufferOffGroups rows into fewer inserts: { maxRows: 500, maxWaitMs: 1000 } by default once set.
lib/eventrail-hub.ts

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.dataEditor on the dataset covers everything the destination does. If the table is provisioned elsewhere, for example with Terraform using the exported bigQueryTableSchema, pass createTable: false; the credentials then only need bigquery.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 maxWaitMs worth) are lost if the process crashes. On a normal shutdown, flush() inserts them. In Next.js, next start finishes pending writes before it exits on SIGTERM, buffered rows included, so you only need flush() 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):

lib/memory-destination.ts

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. name identifies 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.