DEV Community

Cover image for Pyxis: product analytics without cookies, IP addresses or personal data
Samuel Santana
Samuel Santana

Posted on Originally published at samuelsantana.dev

Pyxis: product analytics without cookies, IP addresses or personal data

I had an app getting visitors from ads and no idea what those people did once they arrived: whether they used the calculator on the home page, opened the sign-up, stopped at the Terms, or were on a phone. The default answer was to install Google Analytics. Instead, I wrote Pyxis, open-source product analytics that writes no cookie, keeps no IP address and keeps no personal data. Each of those promises is a small function with a test.

There is a way to try it at the end.

The problem

The app is BolsoVerde, which helps small sellers in Brazil keep track of sales, fees and payouts. The questions I wanted answered were all about the product:

  1. Who visited each page, and where did they come from (an ad, search, a direct link)?
  2. How many got as far as seeing a calculator result?
  3. How many opened the sign-up, how many submitted it, how many finished?
  4. Which features of the app are actually used?
  5. Which requests fail, how often, and on which screen?
  6. What did one person do, in order?

Why build instead of install

Google Analytics, or a product analytics tool like PostHog, answers almost all of that. The cost comes with it: by default, these tools keep an identifier in the browser that recognizes the person for months, and the data goes to another company. For an app that handles small sellers' money, that meant one more company in the Privacy Policy and a measurement that, under the most common reading of the law, would depend on consent. It would only count the people who accepted the banner, and I would be looking at a sample that chose itself.

There are cookieless analytics tools too, some of them open source. I built my own anyway, for three reasons. First, my questions were about the product, not just traffic. I wanted to see a failed request together with the screen it came from, the funnel from the ad to the sign-up, and one person's timeline starting at the anonymous visit before they signed in.

Second, I did not want one more company receiving data. Pyxis runs on the same AWS, the same Neon and the same Vercel the app already used. Third, I wanted to decide every rule: what leaves the browser, how long it is kept, what is never read. In an off-the-shelf tool, those rules belong to someone else. In Pyxis, they are functions anyone can audit.

I also wanted to learn how to build this, in the open.

What is never stored

The design started from a list of what must not exist in the database. Everything else came from it.

  • Cookie: none. A visit is a random id in sessionStorage. It ends when the tab closes and is replaced after 30 minutes without activity. Two tabs are two visits.
  • Page address: reduced to a template in the browser. /vendas/9f1c… becomes /vendas/:id, and email addresses and long runs of digits never leave. The API templates it again on its side.
  • Query string: dropped, except utm_source, utm_medium and utm_campaign. An ad click id (gclid and similar) becomes just from_ad_click: true, and its value is not kept.
  • IP address: read on the spot to limit abuse and to know the country and state, then discarded. Nothing is stored, not even a hash. The city is never read.
  • User agent: classified into device type, browser and operating system, then discarded. Bots that announce themselves are refused, and a browser driven by automation (navigator.webdriver) never starts measuring.
  • Event properties: a barrier on the server drops any value that looks like an email address, a phone number or a Brazilian tax id (CPF, CNPJ).
  • "Do not track": Global Privacy Control and Do Not Track turn the tracker off before anything enters the queue.
  • Retention: 13 months. A daily job deletes what is older.
  • Identified person: only by the site's internal id, never by email. One call erases their events, including those of the anonymous visit in which they signed in, and another exports them.

This has a cost. Pyxis does not know that the person who used the calculator yesterday is the one who created an account today. Without an identifier that lasts, two anonymous visits are never linked. A person only gets a history after signing in, and from then on by the site's internal id. That was a choice. The alternative was an id that lasts for months in the browser, which is exactly what I did not want to keep.

The rules are code, not policy text

Writing in a privacy policy that the IP is not stored is easy. I wanted to be able to point at the function that makes it true, and at its test. That is why every rule is a small, pure function. This one, in the tracker, decides whether a segment of the path is an identifier:

const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ALL_DIGITS = /^\d+$/;
const DIGIT = /\d/g;
const ENCODED_AT_SIGN = /%40/i;
const MIN_ID_DIGITS = 10;

function isIdentifier(segment: string): boolean {
  return (
    UUID_PATTERN.test(segment) ||
    ALL_DIGITS.test(segment) ||
    (segment.match(DIGIT)?.length ?? 0) >= MIN_ID_DIGITS ||
    segment.includes('@') ||
    ENCODED_AT_SIGN.test(segment)
  );
}
Enter fullscreen mode Exit fullscreen mode

The function that builds the template applies it to each segment and replaces anything that is an identifier with :id, after trying the rules the site declared (pathRules: ['/blog/:slug']). The test names describe the rules: turns /vendas/9f1c… into /vendas/:id, turns /u/joao@x.com into /u/:id, handles a path made of thousands of slashes in linear time. The last one exists because the path is the visitor's input, and a privacy rule that hangs on a malicious URL is a problem too.

"Do not track" is the first thing the tracker checks:

export function trackingStatusOf(signals: PrivacySignals): TrackingStatus {
  if (signals.globalPrivacyControl || signals.doNotTrack) {
    return 'blocked-by-browser';
  }
  return signals.optedOut ? 'opted-out' : 'on';
}
Enter fullscreen mode Exit fullscreen mode

The browser's signal wins over the choice made on the page, on purpose. A switch in a site's footer must not turn back on a "do not track" the person set for the whole browser.

The tracker is only the first layer. A project's public key sits in the site's HTML, like any public key, and anyone can send a batch straight to the API without the tracker. So the API templates every path again with the same rules, drops properties that look like personal data and refuses the whole event when the user id looks like an email address or a document number.

The four repositories are at 100% coverage of statements, branches, functions and lines, and CI fails below that. Coverage does not prove a test is right, but it guarantees that no rule was left with nobody looking at it.

Failed requests, in the same visit

When someone gives up on the sign-up, the first thing I want to know is whether the POST failed for that person. For that, the site wraps its own HTTP client once:

import { trackRequest, type RequestMethod } from 'pyxis-analytics';

export async function apiFetch(url: string, method: RequestMethod, init?: RequestInit) {
  const startedAt = performance.now();
  const durationMs = () => Math.round(performance.now() - startedAt);
  try {
    const response = await fetch(url, { ...init, method });
    trackRequest({ method, url, status: response.status, durationMs: durationMs() });
    return response;
  } catch (error) {
    if (!(error instanceof DOMException && error.name === 'AbortError')) {
      trackRequest({ method, url, status: 0, durationMs: durationMs() });
    }
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The URL goes through the same rules and becomes a route (/api/orders/42?coupon=X becomes /api/orders/:id). Status 0 means no response arrived, and an aborted request is left out, because someone leaving the page is not an error. The dashboard shows which routes fail, how often and how slowly, and each request appears in the visit's timeline, in order with the pages and the events.

How it works inside

browser                      AWS, São Paulo                      readers
pyxis-analytics ── batch ──▶ CloudFront ◀──── queries ────────── dashboard (Next.js, Vercel)
path templates                   │      ◀──── MCP, read-only ─── Claude and other agents
no cookie                        ▼
                             Lambda (NestJS)
                             validates each event,
                             drops IP and user agent
                                 │
                                 ▼
                             Postgres (Neon)
Enter fullscreen mode Exit fullscreen mode

The tracker (pyxis-analytics on npm) is under 5 KB gzipped, a budget CI measures on every push, and has no dependencies. The rules live in a pure core, with no window or document. The browser comes in through small adapters (transport, storage, History API, clock) that the tests swap for fakes. A batch goes out as text/plain with credentials: "omit". That way the browser makes no CORS preflight request, sendBeacon works when the tab is hidden, and no cookie travels with it. Every version is published by GitHub Actions with npm provenance, so you can check which commit the package you installed came from.

The API is NestJS on Fastify, on AWS Lambda behind CloudFront, in the São Paulo region, with Postgres on Neon and the infrastructure in Terraform. Three of its decisions:

  • Each event is validated on its own. An invalid event does not sink the batch. The answer is 202 with how many were accepted, how many were duplicates and how many were rejected. A resent event is recognized and never stored twice.
  • Rate limiting without storing the IP. 120 batches a minute per address and 3,000 per project, counted in memory. No counter goes to the database. The address comes from a CloudFront header, which can only be trusted because CloudFront also sends a secret the function checks before doing anything else. X-Forwarded-For is ignored.
  • The dashboard reads the raw events, with no pre-aggregated tables. On a local Postgres with one million events of one project, the 30-day overview answers in about half a second. For the size of the projects Pyxis measures today, an aggregation job would be complexity with no return.

In a load test through CloudFront, 10,000 events over ten minutes were accepted without a single error, with a p50 of 104 ms and a p95 of 140 ms measured on the client.

The dashboard is Next.js 16 on Vercel. You sign in with a six-digit code sent by email, exchanged for an opaque session the server can revoke at any time. The browser only sees an HttpOnly cookie of the dashboard itself, and the Next.js server is what talks to the API. There are eight screens (overview, acquisition, devices, features, funnel, requests, visits and the timeline of a visit or a person), in seven languages, and every Monday a summary of the week arrives by email. The charts are SVG drawn on the server, so no charting library ships to the browser.

The MCP connector lets Claude read the same numbers as the dashboard, through fifteen read-only tools. Pyxis is in Claude's connector directory, and the connection shows up in the dashboard and stops the moment you revoke it. In Claude Code it is one line:

claude mcp add --transport http pyxis https://api.pyxis-analytics.dev/mcp
Enter fullscreen mode Exit fullscreen mode

Since the events carry no cookie, IP address or user agent, neither does anything the agent reads.

No banner? My reading

On the sites where I use Pyxis, it does not ask for consent. The legal basis is legitimate interest under the LGPD, Brazil's data protection law, and the argument is the whole design in this post: no cookie, no identifier that recognizes someone on another day, IP and user agent never stored, no third-party analytics company receiving the data, and, on the other side, transparency in the privacy policy, "do not track" honored and a way to turn measurement off.

That is my reading, not legal advice. If you adopt something similar, check with someone who knows the data protection law where you are, and keep in mind that the conclusion holds for the whole set, not for one piece in isolation.

The limits

Two anonymous visits are never linked, not even two tabs of the same person. That is the price of the most important item on the list.

The counts are a floor. A blocker can refuse the request, and whoever turns on "do not track" is left out of the count, as it should be.

Bots that look like browsers get through. Pyxis refuses the ones that announce themselves, but one that presents itself as an ordinary Chrome counts as a visit. On my own blog, that turned out to be most of the traffic in the first two weeks, so filtering it better is the next thing I am building.

And it is built for small projects. The queries read the raw events, and a project with millions of events a month will need aggregated tables I have not written yet.

If you want to try it

  • Look around without signing up: the demo has the same screens as production, with invented data.
  • Watch the tracker work: the playground runs the real tracker in dry-run mode and shows every batch it would send, with no request leaving the page.
  • Run your own: the code is MIT licensed, with the AWS infrastructure in Terraform included: pyxis-sdk, pyxis-api, pyxis-web and pyxis-mcp. The architecture decisions are recorded as ADRs in each repository.
  • Use the hosted version: the Pyxis home page has the details. For now, it is sold only in Brazil.

The home page link carries ?utm_source=devto. That is exactly what Pyxis keeps from a URL, and it is how I will know whether this post brought anyone.

If you measure a site today, tell me in the comments what would make you switch tools, or not. Criticism of the design is welcome too, especially of the privacy rules: if you find a path where personal data escapes, I want to know.

This is an updated version of the introduction post on my blog.

Top comments (0)