How we built OnyxMetric
OnyxMetric is cookieless web analytics with session replay and an MCP server. Here is how its ingest path, visitor identity, bot detection and replay pipeline work.
- Published
- Written by
- Eliran Yihye
Tags
- engineering
- architecture
- analytics
OnyxMetric is a managed web analytics service. A small browser script sends pageviews and events to an ingest API, the API turns them into anonymous visits without cookies, and the results land in a ClickHouse database that a dashboard, a REST API and an MCP server all read from. This post walks through how that works and what we traded away to get there, as of October 2026.
The problem
Most analytics tools make you pick between accurate numbers and a cookie banner. Cookies are how a tool recognizes a returning visitor, so removing them usually means counting every pageview as a new person. We wanted numbers a team could trust with no consent banner, plus session replay and AI assistant access in the same product.
That shaped three constraints. Identity has to work without storing anything on the visitor's device. Bots have to be filtered before they pollute the counts. And replay, which produces far more data than pageviews, must not be able to take the rest of the system down.
We did not start from a blank page. OnyxMetric began from the open-source Rybbit project under AGPL-3.0, and the first job was removing outbound telemetry so the server calls no vendor service.
Architecture
The tracker in server/src/analytics-script is bundled by esbuild into a single IIFE targeting ES2020, in a minified and an unminified build. It posts to /api/track on a Fastify server written in TypeScript. Postgres through Drizzle holds accounts, sites and settings. ClickHouse holds events. Redis holds counters and short-lived identity state. The dashboard is a separate Next.js app, and the MCP server is mounted on the same Fastify instance using the official TypeScript SDK over streamable HTTP.
Data model and pipeline
The events table is a plain MergeTree, partitioned by toYYYYMM(timestamp) and ordered by (site_id, timestamp). Almost every dashboard query filters by site and a time range, so that key lets ClickHouse skip nearly everything else. Every time column carries an explicit 'UTC' timezone, because a bare DateTime takes the server's zone and would shift stored instants on a ClickHouse configured for anything else. A timestamp_ms DateTime64(3, 'UTC') column keeps events from the same second in order.
Writes never go row by row. pageviewQueue.ts appends each pageview to an in-memory array and flushes it every 1,000 ms, up to 5,000 rows per flush. ClickHouse prefers few large inserts to many small ones. Before inserting, the flush looks up geolocation once per unique IP in the batch instead of once per event.
Sessions come from a session service keyed in Redis with a 30 minute idle window, and channels such as organic search or social are derived from the referrer and URL parameters in getChannel.ts.
Trade-off 1: anonymous identity without cookies
userIdService.ts computes a visitor ID as the first 12 hex characters of a SHA-256 over the IP address plus a user agent. Two details matter.
First, the user agent is version-stripped (normalizeUserAgent.ts) before hashing. Otherwise a browser update would mint a new visitor, and a synchronized app rollout would do it to everyone at once. The test fixtures are real user agents captured from production traffic.
Second, many visitors sit behind proxies, Private Relay or VPNs that rotate IPs mid-visit. For addresses in datacenter networks, identityIpBucket.ts hashes a /24 (IPv4) or /48 (IPv6) bucket instead of the exact address. That deliberately trades splitting for merging: distinct visitors behind the same proxy with the same user agent collapse into one, which we judged far less damaging than one visitor exploding into many.
Rotation can still cross a bucket boundary, so stickyUserId.ts re-attaches a new fingerprint to an old identity, but only when exactly one candidate with the same user agent was active through datacenter egress in the last 15 minutes. With zero or several candidates it abstains, so the failure mode stays "split", never "merge". If Redis is down, the raw fingerprint is used. Sites can also enable a salt derived from the server secret and the UTC day of the event itself, so identities cannot be linked across days.
Trade-off 2: bot filtering from two sides
The tracker computes a weighted score and a bitmask from environment signals: automation APIs, zero outer dimensions, software rendering, default viewports such as 800 by 600 and others. Fourteen signals are defined in shared/src/botSignalContract.ts, a module the tracker and the server both import, so the bit layout and weights cannot drift apart. The tracker build aliases that one file instead of the package, so tree shaking keeps everything else out of the page.
The server does not trust the client. It re-derives what it can see itself, such as header heuristics, known bot user agents and hosting-provider ASNs, and then decomposes the mask into signals that convict alone and signals that only corroborate. Flagged traffic is written to a separate bot_events table with a three month TTL, so a blocked event stays inspectable.
Trade-off 3: replay with a byte budget
Replay uses rrweb in the browser, with inputs masked by default. The uploader retries a failed batch three times with 2, 4 and 8 second backoff, then drops it, and keeps keepalive requests under the 60 KB browser limit.
On the server, sessionReplayIngestService.ts stores each batch in object storage when it is enabled, falling back to ClickHouse if that write fails, and records only metadata in ClickHouse. A Redis counter tracks bytes per session. One measured session reached 297 MiB over 2,500 batches, so ingest now stops accepting batches past a 50 MiB cap, and playback loads at most 40 MiB so one runaway recording cannot exhaust a worker's memory.
The metadata table taught the hardest lesson. The first design kept one cumulative row per session and re-read the whole session on every batch. In one six day window that single query scanned 818 billion rows, 76 percent of everything the cluster read, and left 2.5 million tiny parts. session_replay_metadata_v2 is an AggregatingMergeTree: each batch inserts only what it saw and ClickHouse combines rows at merge time, so ingest never reads back.
The MCP server
server/src/mcp registers tools for analytics, funnels, goals, segments, users, sites, organizations, raw data and Search Console. The tool list reflects the credential's granted scopes, and roles run from viewer to owner. Server instructions tell the model that returned page titles, referrers and queries are untrusted data, because analytics content is attacker controlled by nature.
Shipping it
Production deploys build the backend, dashboard and marketing site from one commit, smoke test them and roll back automatically. We wrote that rule down after a single-image rebuild left a mismatch that broke the dashboard for 16 hours.
What is next
The replay v2 cutover notes record a known limitation: snapshot columns such as geography merge independently. In 30 days of production, about 4 percent of the 423 sessions with more than one metadata version could show a city and country from different batches. Fixing that is documented work in the repository.
OnyxMetric is built and run by Cuanto Labs, a software studio in Fort Lauderdale, Florida. See the leadership section on the About page or the documentation.
Frequently asked questions
Does OnyxMetric use cookies?
No. The tracker does not set cookies or use local storage for visitor identity. The server derives an anonymous 12 character identifier by hashing the IP address and a version-stripped user agent, optionally with a salt that rotates every UTC day.
Where is analytics data stored?
Events and replay metadata live in ClickHouse, application state such as accounts and site settings lives in Postgres, short-lived counters live in Redis, and replay payloads can be stored as objects in S3 compatible storage.
Is OnyxMetric open source?
OnyxMetric began as a fork of the open-source Rybbit project, which is licensed AGPL-3.0, and keeps that license. Cuanto Labs removed the upstream outbound telemetry and operates the product as a managed service.
How does the MCP server stay safe?
Every tool is gated by the scopes of the API key or OAuth grant and by the caller's role on the site or organization. Values returned from analytics data, such as page titles and search queries, are labeled as untrusted data and never as instructions.