Docs

This is what's actually running.

Writing docs for something nobody can sign up for is a slightly odd exercise, I'll admit. But the shapes below are real: typed, tested, and taking events today. The missing part is the one where you get an account.

First up: what a browser sends

Your visitor's browser sends this to a path on your own domain, and your proxy passes it along. Short and boring, which is what you want from something that runs on every page view.

{
  name: "pageview",         // lower_snake_case
  props: {                  // optional, up to 16
    plan: "pro"
  },
  ctx: {
    path: "/pricing",
    route: "/pricing",      // optional: your framework's pattern
    ref: "google.com"       // optional
  }
}

There's nothing in there naming which app it came from. That's on purpose. See below.

And what your backend sends, which is different

If the event happened somewhere a browser can't see — an import finished, a subscription lapsed — your server sends it instead, and it can tell me who it was for. Your server has verified that person's token, which a browser can't do for itself. So the browser shape has no field for it.

{
  name: "import_completed",
  ctx: { route: "/unit/[unitName]" },   // the template, not the real path
  subject: {                            // required here, forbidden above
    iss: "https://you.auth0.com/",
    sub: "auth0|653f..."
  },
  client: {                             // optional
    ua: "Mozilla/5.0 (iPhone; CPU..."   // the browser that caused this
  }
}

That subject gets hashed the moment it arrives and is never written down anywhere. Note route rather than path: your real URLs probably have customer names in them, and I'd rather not have those.

client.ua is how device and browser end up on a server event. Send the user agent from the request that caused it — the one your user's browser made to you, not the one your HTTP library sends me. I read it, work out desktop or phone and which browser, and drop the string. If a cron job or a webhook triggered the event and there was no browser involved, leave it out.

There's one secret, and the browser never sees it

Every request carries a secret header. Your edge injects it, or your backend holds it. The browser never gets to see it, which means there's nothing on the client worth stealing.

That header is what says which tenant and which app this is, which is why there's no app field in the event itself. If a browser could set it, anyone could send events as anyone else.

Two things there's no field for

There's no field for who the visitor is, and none for when it happened. The schema has nowhere to put either one, so there's nothing for me to check and reject.

Anonymous visitors get an identifier I work out on my side. Timestamps come from my clock, not yours, because client clocks are wrong, skewed, or occasionally lying.

Naming things, the second hard problem

Names are lower_snake_case. Register the ones you want counted; anything else still gets stored, it just doesn't get its own running total. Registering is what keeps the dashboard fast and the bill small.

Query strings get stripped on the way in. If one of your URLs has a password reset token in it, I don't keep it.

Paths themselves I leave alone. /user/1 and /user/2 are two different pages as far as I know, and if you want them counted as one, that's what ctx.route is for. Your framework knows the pattern. I'd only be guessing.

TellWright — analytics for small projects. It works; it's just not open yet.

Built by Esposito Holdings, LLC. This page sets no cookies and asks nothing of anybody else, which did rather feel like the bare minimum given the topic.