OwlEye Blog

How We Built a Cookie-Free Analytics SDK

Removing cookie writes is straightforward. Building useful analytics around that constraint means deciding what a visitor count represents, when a route becomes a page view, and what happens when the browser closes before a request finishes.

This is a tour of OwlEye’s implementation and its limits. The SDK reference documents the supported configuration and lifecycle.

Start with a small collection surface

The public tracking SDK does not write cookies, localStorage, or sessionStorage. It excludes query strings and URL fragments by default, omits credentials from event requests, and suppresses their HTTP referrer header. The event payload can still include a sanitized referrer and page URL.

Those defaults reduce accidental collection. They cannot rescue an event that deliberately includes an email address or a URL path containing a private document token. Custom properties should describe the action, not identify the person.

import { useAnalytics } from "@owleye/analytics";
const analytics = useAnalytics("owl_your_tracking_id");
analytics.track("report_exported", { format: "csv" });

Send that event after the export succeeds. Calling it on the export button would measure attempts instead. Global and per-event properties share a ten-property limit; keep categorical values short and review the combined payload.

A visitor count needs an honest definition

The server derives keyed, site-scoped pseudonyms from a reduced IP prefix, user agent, and timezone context. OwlEye uses keyed BLAKE3 with a configured secret. The visitor pseudonym can remain stable across days while those inputs and the secret remain unchanged. A separate user pseudonym rotates daily, and a fixed 30-minute time bucket supports sessions. No persistent identifier is written to the visitor’s browser.

This is an estimate of activity, not a count of verified people. Shared networks and similar devices can merge activity; changes in context can split it. Daily user pseudonyms change at the UTC day boundary. A fixed session bucket can split activity across its boundary even without 30 minutes of inactivity.

The secret is not a freshly generated daily salt, and hashing does not establish legal anonymity. Raw IPs are not written to the analytics facts, but the server receives a network address, and infrastructure logs need their own review. The privacy documentation explains the boundary.

Page views and lifecycle events are different signals

A single-page app changes routes without loading a new document. The SDK observes supported browser navigation and counts pathname changes, with query and hash changes opt-in. Reinitializing the same active tracker should not produce another initial view.

Page-duration updates are separate from page views. Hiding a tab should finish a visible interval, not invent a navigation. The SDK uses visibility changes and page lifecycle handling so it can record those intervals while the document still has a chance to transmit them.

If a memory router does not update browser history, disable automatic views and connect its committed-route callback to pageview(). Do not count the same transition in both places. The SDK reference covers that integration.

Delivery has a finite budget

The SDK uses JSON requests with fetch and keepalive: true. In the current source, entrypoints share limits of eight pending event requests and 60,000 in-flight body bytes. These bounds avoid unbounded background traffic; they are not reserved capacity in the browser.

Why not promise that sendBeacon solves delivery? A successful beacon return means queued, not stored. Cross-origin JSON can require a preflight, and background request capacity is limited. Closing a browser, losing connectivity, or exceeding those limits can still lose events. The Beacon specification describes those constraints.

OwlEye has no persistent offline event queue. Analytics should therefore measure product behaviour, while your transaction system remains the source of truth for payments or completed orders.

Keep optional work optional

Core page analytics, rules, and explicit performance timing have separate entrypoints. A site that needs page views should not silently pay for a rules engine it never uses.

The source-build snapshot reported for this article measured the complete core ESM graph at about 6.90 KiB gzip and the standalone core browser bundle at 7.30 KiB gzip. These are build measurements, not a promise that every published package or consumer build has identical bytes. Optional entrypoints add code. Reproduce the measurement in your checkout:

Terminal window
pnpm --filter @owleye/analytics build
pnpm --filter @owleye/analytics size

Zero runtime dependencies still leaves parsing, execution, and network work. We use size budgets to catch growth and tests to protect lifecycle behaviour; we do not infer a zero Lighthouse penalty from a small download.

Make collection controllable

Global Privacy Control is respected by default in the SDK. If your deployment requires a permission decision before collection, initialize with autoStart: false, then call start() only when allowed. Initialize optional trackers after that decision too, and stop every active tracker when permission is withdrawn.

Define the event, inspect the request, test the route lifecycle, and interpret the resulting count according to how it was made. Then use the optional dashboard rules workflow to add supported interactions without another code change for each event, and read the recorded steps in a funnel.