Why I implemented my own analytics for TheBest.Ink
2026-09-18
2026-09-18
A tattoo artist puts a link in their Instagram story. It points at their booking page on TheBest.Ink , the platform I build: the page that shows their work and carries the form a client fills in to ask them for an appointment. The story runs for twenty four hours and then it is gone. Did anybody click it?
That is the entire question. Not sessions, not bounce rate, not a funnel. An artist wants to know whether the thing they just did in the app they actually use sent anyone to that page, and whether anyone finds them on TheBest.Ink without Instagram's help at all.
I built a views dashboard for that. It is smaller than anything I would have built two years ago, and the interesting part is why.
Years ago I wrote my own analytics service, basic_analytics, because I did not want Google Analytics on my sites and I did not want the cookie dialog that comes with handing visitor data to a third party. Django, chart.js, an endpoint that takes a payload per page view, and charts for monthly views, top URLs, browsers, countries, devices.
It works and I still like it, and it is the wrong shape for this problem. It answers "what is happening on my website", which is a question a site owner asks, and the artist is not the site owner: they do not care about my top URLs, they care about their three pages. It also stores an event per view, so the table grows with traffic and the dashboard has to aggregate it on every read. That is the right call for a tool that might later slice by country and device, and paying for it forever is a bad deal for one that never will.
Plausible, Umami, Matomo, PostHog, Fathom, GA4. They are good, several are self hostable, and I discarded all of them.
Every one is built for the person who owns the site, so making one into a per-artist dashboard means mapping each artist onto whatever tenancy concept the tool has, keeping that mapping in sync as artists and studios come and go, and still proving on every request that this artist may see this data. The authorisation problem, the only genuinely delicate part, stays entirely mine. The tool just becomes a second system that has to be up for my dashboard to render.
Here is the actual reason, and it would have sounded like arrogance a few years ago. Writing the boring three hundred lines myself has gotten much cheaper, and taking on a dependency has not. The cost of a dependency is not the integration, it is the years afterwards: the upgrade that changes its schema, the outage you cannot debug, the feature you need that it will not add. What got cheaper is typing out a counter, a classifier, a serializer and a chart, with the tests and the three translations. So "build it yourself" moved from romantic to boring, and boring is a good reason.
That only holds if you also refuse to build the hundred metrics. The point was never that I could write Plausible. It was that I did not have to.
One row per page, per day, per channel, holding a running count.
page_type artist | studio | booking
artist FK, set for artist and booking pages
studio FK, set for studio pages
date the day
source direct | internal | search | instagram | facebook | tiktok | ...
count a running integer
A view increments today's row instead of inserting a new one, so the table grows with pages and days, not with traffic. Ten thousand views on a profile in one day add a handful of rows, one per channel it was found through.
One detail of that schema cost me an afternoon: Postgres does not treat NULL as equal to NULL, so a unique constraint over a nullable foreign key deduplicates nothing, and the artist rows and studio rows each need their own partial unique index.
The obvious way to count a page view is to count it while rendering the page. That does not work here, and finding out why was most of the actual work.
The public pages are served from a cache: one in Next.js over the data fetch, and a per-object cache in the Django API underneath it. That layering is good for the pages and fatal for counting, because a cached render does not run. Counting inside the data fetching layer counts cache misses, so the reported number would be views multiplied by the cache miss ratio. It would look plausible, be wrong by a factor nobody can see, and move whenever I tuned the cache.
So the count comes from the browser. Each page renders a tiny client component whose only
job is to send one small request after it mounts, saying "this page was just opened", to
an endpoint that counts it, with a ref guarding against React's Strict Mode double effect.
That request never throws, uses keepalive so it survives a visitor navigating away
immediately, and swallows every failure. Analytics must never break the page it is
measuring.
The referrer has the same problem one level deeper. The natural place to read where a
visitor came from is the Referer header, and it does not exist here: the page came out
of a cache, so there is no per-visitor request to read it from, and the view report's own
Referer says my page, because my page is what sent it. The only place the information
exists is document.referrer, so the view report carries it.
Which creates the next problem. The endpoint is unauthenticated, because every visitor to a public page hits it, so a referrer stored verbatim would let anyone mint unbounded rows by varying it. The column is a fixed vocabulary of channels instead, and the referrer is folded onto it server side, which caps the fan out at one row per page, per day, per channel no matter what anyone sends.
Folding it server side rather than in the frontends pays off immediately, because the
classifier is wrong the first time in ways you only learn in production. Instagram sends
visitors through l.instagram.com, so the bare domain never appears. LinkedIn rewrites
every outbound link to lnkd.in. Google and Pinterest run a domain per country, so
matching a host list is wrong the day somebody visits from a locale nobody thought of.
Every one of those was a correction I shipped without touching a frontend. Nothing is ever
rejected, either: an unparseable referrer counts as direct and an unknown host as other
website, because a view is worth counting even when its origin is a mystery.
Every view of a page is one more increment on the same row, today's row for that page. So the endpoint that is called more often than any other one I have, and matters least, has all of its callers changing the same row at the same time. Both of those had to be designed for.
The database has ATOMIC_REQUESTS on globally and this view opts out, so autocommit
releases that row the moment its UPDATE finishes instead of holding it until the
response is rendered. The write is an UPDATE ... count = count + 1, and only if that
touched no rows does it insert, inside a small atomic block, catching the integrity error
from losing that race and incrementing instead.
The obvious next move is to not touch Postgres at all: buffer the increments in Redis, one key per page per day per channel, and flush them into the table on a schedule. Redis is already running here for throttling and as the Celery broker, so it is not even a new dependency, and it is the standard answer for a counter written this often.
I did not build it. A buffer adds a second store that has to agree with the first, a flush job that can fail quietly, and a window in which a restart loses every count not yet flushed. That last one is the part that bothered me: the whole point of the buffer is to delay the durable write, so losing views on deploy is not a bug in the design, it is the design.
And I had no evidence I needed it. The load test floods the endpoint at 500 view reports per second for thirty seconds, on a laptop, alongside heavy uploads on the booking form. With the uploads idle, one percent of the view reports are turned away at the door by a limit I describe below, and ordinary traffic sits at 2.15 ms at p95. The flood of view reports on its own is a non-event. What actually degrades the server is ten image uploads in one request, which has nothing to do with this table.
The same test removes the correctness argument too. It asserts that the views totalled in
the database exactly equal the number of 204s the client got, and exactly is the word:
that is the check that concurrent increments do not lose each other, and incrementing in
the UPDATE is atomic, so it has held at every load tried. A buffer would not have made
the counting more accurate. It would have made it less accurate, in exchange for speed I
did not need. So it is the right fix for a load I do not have, and what matters is the
trigger: when view reports start being turned away while nothing is uploading, the rate of
views has become the problem and Redis is the answer.
The paths that count nothing matter more than the path that counts. An off switch for the whole feature and a bot filter both return before authentication, throttling, body parsing and any database access, because parsing a crawler's body to then discard the result is work with no outcome. Above the application, the reverse proxy lets at most four view reports be handled at the same moment. A fifth arriving while those four are still being answered is refused on the spot with a 429, without waiting and without touching the server behind it. Four is half of the eight requests the server can work on at once, and that is the actual policy: analytics may take half the server and no more. And in the client, the shared request helper retries failed requests, but this one call is pinned to a single attempt, because a 429 is the server asking for less, and retrying it would send more at exactly that moment. A dropped view is the intended outcome there.
That last sentence is the one I would keep if I could keep only one. Almost every decision in this feature follows from being willing to say it out loud.
There are no unique visitors. The dashboard says "Views" and "how often your pages were opened", and that is literally what the number is. If an artist reloads their own profile eleven times, that is eleven views.
I know how I would fix it. Without cookies, which is what the good hosted tools do: hash the IP address and user agent with a salt that rotates daily, keep the hashes in Redis with a one day expiry, and treat a repeat hash as the same visitor. With a cookie it is simpler to reason about and worse to ship, because a first party identifier is not strictly necessary for providing the service, so in the EU it needs a consent dialog, and now every visitor to every artist's page meets a banner to make a number in someone else's dashboard slightly better.
I did neither, and not only because of the banner. Unique visitors breaks the data model.
count = count + 1 cannot deduplicate, so a distinct count means keeping the visitors
themselves, one set per page per day: a key store that grows with traffic, a second store
the dashboard has to read from, and an expiry policy for it. What makes this feature cheap
to own is that it is one small table.
So I was honest about the number instead. The absolute value is inflated. What survives the inflation is the shape: the day the story went up against the days around it, Instagram against direct against search, this week against last. An artist asking "did the story work" is asking for a comparison, and a comparison tolerates a constant factor.
The dashboard shows an area chart per page over seven, thirty or ninety days, a card per page with its total, and a ranked list of where the visitors came from.
The feature is roughly one table, one endpoint, one classifier, one stats builder and one chart. It is not the analytics I would have built if I had asked what analytics should contain. It is what was left after asking what the artist wants to know, and then refusing to build anything else.