What a click actually tells you
One HTTP redirect yields 28 stored fields. Four are facts, several are educated guesses, one is mostly wrong. A field-by-field audit of link analytics.
- analytics
- engineering
- data
A click on a short link is one HTTP request and one 308 response. There is no JavaScript, no pixel, no second round trip. From that single request gecis.in writes 28 fields into click_events.
That number sounds like a lot, and analytics products are usually happy to let you assume that 28 fields means 28 facts. It does not. Some of those fields are as close to ground truth as anything in web analytics gets; several are statistical guesses presented as strings; one is systematically misleading in a way that has been getting worse every year.
This is the audit. Fields grouped by what they are, then ranked by how much weight they can carry.
The 28 fields
CREATE TABLE click_events (
id bigserial,
link_id bigint NOT NULL,
clicked_at timestamptz NOT NULL DEFAULT now(),
-- identity
ip inet, ip_hash bytea NOT NULL, visitor_key text,
-- geography
country text, region text, city text, continent text,
latitude numeric(9,6), longitude numeric(9,6),
-- device + software
device_type text, device_vendor text, device_model text,
browser text, browser_version text, os text, os_version text,
engine text, ua text,
-- source + campaign
referer text, referer_url text,
utm_source text, utm_medium text, utm_campaign text,
utm_term text, utm_content text,
-- other
trigger text NOT NULL DEFAULT 'link',
is_bot boolean NOT NULL DEFAULT false,
PRIMARY KEY (id, clicked_at)
) PARTITION BY RANGE (clicked_at);One time field, three identity, six geography, nine device, two source, five campaign, two classification. id and link_id are bookkeeping and are not counted.
Everything after clicked_at is derived from four request inputs: the client IP, the User-Agent header, the Referer header, and the query string. That is the entire raw material. It is worth holding that in mind while reading the rest: 28 columns produced from four inputs means most of the columns are inference.
Tier 1: things that are true
clicked_at. A request reached our process at this instant. timestamptz, stored UTC. The only caveat is a reporting one: "clicks per day" depends entirely on which timezone you bucket by, and a campaign that looks like it peaks at 09:00 in UTC peaks at a different hour for its actual audience. Pick the audience's timezone, not the server's, and say which one you picked.
link_id and the fact of the request. Somebody or something asked for this slug.
trigger. qr or link, from a ?qr=1 marker we put into the URL encoded in the QR image. This is reliable in the direction that matters — if it says qr, the request came from a URL we generated for a QR code — because nobody types those by hand. It is our own bookkeeping and is stripped before forwarding.
The UTM parameters. Reliable as a record of what was in the URL. Not reliable as a statement about where the traffic came from: they are self-reported by whoever built the link, and anyone with the short URL can append whatever they like. Treat them as labels, which is what they are.
Tier 2: usually right
country. We take it from Cloudflare's CF-IPCountry header when present and fall back to MaxMind. Country-level IP geolocation is the one geolocation claim that holds up: commercial datasets are typically accurate at the country level in the high nineties of percent. The systematic exceptions are worth knowing:
- VPNs and proxies resolve to wherever the exit node is. This is not a small effect on consumer traffic.
- Corporate egress. A multinational routing all traffic through one datacentre puts every employee in that country.
- Satellite and some mobile carriers can egress in a different country from the user.
- Cloudflare sends
XXfor unknown andT1for Tor exit nodes; both normalise toNULLfor us rather than to a country.
device_type. Desktop / mobile / tablet, from User-Agent parsing. The three-way split is coarse enough to survive the degradation of user-agent strings, and it is the device field most worth trusting. Tablet-versus-mobile is the weak edge: iPadOS has requested desktop sites by default since iPadOS 13, so a meaningful share of iPad traffic identifies as macOS desktop and there is nothing in the header to correct it with.
browser, os, engine — the names. Chrome is Chrome. The versions are tier 3, see below.
is_bot. Honest but asymmetric. Our detection is ua-parser-js plus a substring list of about forty markers — bot, crawler, spider, facebookexternalhit, whatsapp, slackbot, python-requests, curl/, headlesschrome, and so on. A true positive is almost certainly a true positive. A negative means nothing: any bot that wants to look like Chrome sets the User-Agent to Chrome, which costs one line of code.
The reason we care is not crawler hygiene, it is a specific and large distortion: link preview fetchers. Paste a short link into a WhatsApp group, a Slack channel or a Discord server and the platform immediately fetches it to build a preview card. Every one of those is an HTTP request to your redirect, from a datacentre IP, before any human has clicked anything. On a link shared into a few busy channels this can be a double-digit percentage of your "clicks", concentrated in the first minutes — exactly the window in which people judge whether a campaign is working.
Tier 3: educated guesses
city, region, latitude, longitude. These come from MaxMind's GeoLite2-City database, and the accuracy gap between country and city is enormous. MaxMind publishes per-country accuracy figures for city-level resolution and they are far below the country-level numbers; GeoLite2, the free tier, is a coarser dataset than the commercial GeoIP2. City should be read as "a plausible city in roughly the right area", never as a fact about a person.
The coordinates deserve a specific warning. They are not a location. When an IP block cannot be placed precisely, geolocation databases fall back to a representative point — the centroid of the region, or of the country. Those defaults are not distributed evenly; they pile up on a handful of coordinates. The best-documented case is a farm in Potwin, Kansas, which for years received a stream of visits from people who had traced abuse, fraud and missing phones to the geographic centre of the United States, because that was the fallback point for tens of millions of unplaceable US addresses.1
So: plot the coordinates on a map if it makes a nice visual, but never draw a radius around one and never present it to a customer as where somebody was.
browser_version, os_version, device_model, device_vendor. These are getting worse on purpose, and it is worth understanding why so you do not build a report on them.
Chrome's User-Agent Reduction, rolled out through 2022–2023, froze most of the string. The browser minor version is reported as 0.0.0, the platform version is frozen, and on Android the device model is replaced with a generic value for most traffic. Safari froze its string years earlier. The replacement mechanism is User-Agent Client Hints (Sec-CH-UA, Sec-CH-UA-Platform-Version, Sec-CH-UA-Model), and here is the part specific to a shortener:
Client Hints beyond the three low-entropy defaults require a negotiation. The server responds with Accept-CH and the browser sends the requested hints on a subsequent request to that origin. A redirect endpoint gets exactly one request and then the visitor is gone to somebody else's domain. There is Critical-CH, which asks the browser to retry the current request with the hints attached — but spending an extra round trip on the critical path of a redirect, to improve a field nobody makes decisions with, is not a trade we are willing to make.
The practical upshot: browser is useful, browser_version is a low-resolution bucket, and device_model for Android is mostly generic. We store them because they are free and occasionally decisive in debugging. We do not build features on them.
Tier 4: the one that is mostly wrong
referer and referer_url.
The Referer header (misspelled in the original RFC and never fixed) once told you the exact page a visitor came from. Three changes have hollowed it out:
- Default referrer policy. Chrome and Firefox moved to
strict-origin-when-cross-originas the default around 2020. Cross-origin, you receive the origin only —https://news.example/, neverhttps://news.example/2026/article-slug. Soreferer_urlis, for most modern browser traffic, no more informative thanreferer. - HTTPS to HTTP sends nothing. By design, and it is why the residual "direct" traffic from older sites is not really direct.
- Native apps mostly send nothing at all. A link tapped in an iOS Mail message, a WhatsApp chat, a native Twitter or LinkedIn client, a PDF, or a QR code scanner arrives with no
Refererwhatsoever.
Add these up and, for a typical short link, the largest single bucket in your referrer breakdown is "none" — and that bucket is not a channel. It is the union of every app, every QR scan, every pasted URL and every privacy-conscious browser. Reading it as "direct traffic" is the most common misreading in link analytics, and it gets worse in exactly the situations where short links are most useful, because short links are mostly used in apps.
This, incidentally, is the strongest practical argument for UTM discipline. UTMs are the only source attribution that survives the app boundary, because they travel in the URL rather than in a header the platform chose not to send.
We also set Referrer-Policy: strict-origin-when-cross-origin on our own redirect, so the destination does not receive the full short URL with its campaign tail. That is deliberate: a short link should not leak its own query string into somebody else's logs.
How not to over-read your own analytics
A redirect is the end of your visibility. There is no session, no scroll depth, no bounce rate, no conversion. Anything downstream of the click has to come from the destination's own analytics, joined on the UTM parameters. A shortener that shows you a "conversion rate" is either receiving a callback you configured or making it up.
Small numbers are noise. The 95% confidence interval for a proportion is roughly p ± 1.96·√(p(1−p)/n). On 300 clicks at a 5% rate that is ±2.5 percentage points — so a variant showing 4% and one showing 6% are not distinguishable. Link analytics produces small denominators far more often than web analytics does, and a per-city or per-browser breakdown slices a small number into much smaller ones. Our dashboards show counts alongside percentages for exactly this reason: 2 of 31 is honest in a way that 6.5% is not.
Cross-day uniques do not exist. visitor_key is scoped to a single UTC day by construction, so the sum of daily uniques is not the weekly unique count and there is no query that recovers it. We do not show a monthly unique figure because we could not defend it.
Bots inflate the beginning. The first minutes after a link is shared are the most bot-contaminated. Judge a campaign after the preview fetchers have finished, and filter is_bot when you do.
Absence is not a signal. A NULL city means MaxMind did not place the address, not that the visitor was nowhere. A NULL referer means the header was absent, not that they typed the URL. Every one of these columns is nullable and the nulls are structural, not missing data.
What we actually look at
For most links, four numbers carry nearly all the information: clicks over time, unique visitors per day, country, and utm_source when the link is tagged. Device type is a distant fifth and matters mostly when it is surprising.
The other 23 fields earn their storage in a different way: they are what you need on the day something is weird — a spike from one ASN, a slug that is suddenly getting scanned, a redirect that only misbehaves on one OS version. Keeping them is cheap; presenting them as insight is not.
Footnotes
-
Reported by Kashmir Hill in 2016 ("How an internet mapping glitch turned a random Kansas farm into a digital hell"). MaxMind subsequently changed the default coordinates for unresolvable US addresses. ↩