Skip to content

Browser beacon

The browser beacon records route changes within a single-page app (SPA) and custom events such as signups or purchases. These actions may not make a page request that CloudFront can log.

Deploy Beacon path first, then add the module to your site’s existing JavaScript bundle:

import { startBeacon } from "@kensio/rainlytics/beacon";
const beacon = startBeacon();

The beacon sends GET requests to /_rainlytics on the site’s own domain. A CloudFront Function returns 204 and CloudFront records each request in the access log. Requests use fetch with credentials: "omit" and keepalive: true. The beacon sends no cookies and creates no browser identifier.

startBeacon watches history.pushState, history.replaceState and browser navigation. It reports a route event when the visible path changes.

CloudFront already logged the document request for the first page. The beacon begins with later route changes, avoiding a duplicate view.

Report a custom event with report:

beacon.report({
event: "signup",
page: location.pathname,
});
beacon.report({
event: "purchase",
page: location.pathname,
value: 4995,
subject: "SKU-1234",
});

A request can contain several events. Each event has an event name and a page path. Optional fields are value for a number, subject for what it measures (such as a SKU), and message for text. CloudFront stores one log record per request. Rainlytics queries unpack that record into one row per event.

Keep personal data out of event names, pages, subjects and messages. These values remain in the raw log until its lifecycle expires them.

Pass several events to one report call to send them together:

beacon.report(
{ event: "purchase", page, value: 4995, subject: "SKU-1234" },
{ event: "purchase-line", page, value: 1999, subject: "SKU-9876" },
);

Each call sends immediately. Separate calls make separate requests. If a batch would exceed the request size limit, the beacon splits it across several requests.

Under CloudFront pay-as-you-go pricing, beacon requests incur request and function invocation charges. Their log records also contribute to S3 storage and Athena scans. See Beacon path.

Under a CloudFront flat-rate plan, beacon requests count toward the monthly request allowance. Sending several events in one request reduces that usage. Monitor request counts as well as data transfer.

Rainlytics stores value without a unit or currency. For money, send an integer in the smallest currency unit. For example, send 4995 for £49.95 or $49.95. This avoids floating-point rounding errors when summing amounts.

Choose a unit for each event name before collecting data and keep it consistent. Mixing pounds and pence, or different currencies, produces an invalid total. See Rollups for the query that sums these values.

Each event can hold one number, one subject and one message. To report an order and its individual items, send separate event names for the order total and the item totals:

beacon.report({
event: "purchase",
page: location.pathname,
value: order.totalInPence,
subject: order.reference,
});
for (const line of order.lines) {
beacon.report({
event: "purchase-line",
page: location.pathname,
value: line.totalInPence,
subject: line.sku,
});
}

Sum purchase events to measure revenue. Group purchase-line events by subject to measure sales of each item. Summing both event names together would count the same money twice.

Keep the number of events per action small. An order with fifty lines is fifty events, and even batched into one request it is fifty rows once a query unpacks it.

Vitals use a separate entry point:

import { reportVitals } from "@kensio/rainlytics/beacon/vitals";
reportVitals(beacon);

Rainlytics reports:

Event Measurement Unit
ttfb Time to First Byte milliseconds
fcp First Contentful Paint milliseconds
lcp Largest Contentful Paint milliseconds
cls Cumulative Layout Shift score

TTFB waits up to four seconds for FCP so they can share one request. LCP and CLS share a request when the document first becomes hidden. CLS uses the worst session window and ignores shifts after recent user input. See Web Vitals for timing and calculation details.

Rainlytics does not calculate Interaction to Next Paint (INP). You can collect it with the web-vitals library and report the result:

import { onINP } from "web-vitals";
onINP(({ value }) => {
beacon.report({ event: "inp", page: location.pathname, value });
});

The shipped web-vitals rollup currently reports TTFB, FCP, LCP and CLS.

Error reporting also uses a separate entry point:

import { reportErrors } from "@kensio/rainlytics/beacon/errors";
reportErrors(beacon);

Uncaught exceptions use event name error. Unhandled promise rejections use rejection. The message is limited to 200 characters and no stack trace is sent.

Error messages can contain email addresses, account names and other personal data. Redact them before they reach the immutable log:

reportErrors(beacon, {
redact: (message) => message.replace(/\S+@\S+/gu, "[email]"),
});

Return undefined from redact to drop a message. Query-time cleanup is too late to remove the raw value.

The site owns the consent decision. Start the beacon after the site’s consent flow approves it:

const beacon = consented ? startBeacon() : undefined;

Stop reporting when consent is withdrawn:

beacon?.stop();

stop restores the original history methods. Later report calls send no requests.

const beacon = startBeacon({
path: "/_measure",
reportRoutes: false,
});

path defaults to /_rainlytics and must match BeaconPath. Set reportRoutes: false when your framework already provides a router hook and you only want explicit report calls.

The base beacon is currently 707 bytes gzipped. Vitals and errors together bring the complete set to 1,559 bytes gzipped. Project checks bundle, minify and gzip each entry point and fail when a size budget is exceeded.

The browser target is Baseline 2022 (Chrome and Edge 108, Firefox 108 and Safari 16). A browser without fetch keepalive still attempts the request, but its final event is more likely to be lost during navigation.