Skip to content

feat: add the /go route that counts outbound clicks - #3526

Merged
ether-eye merged 6 commits into
devfrom
feat/outbound-go-route
Nov 6, 2026
Merged

ether-eye merged 6 commits into
devfrom
feat/outbound-go-route

Conversation

@ether-eye

@ether-eye ether-eye commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

What: this adds /go?to=<url> and outboundHref.

  • /go counts one outbound_click on the server, then redirects.
  • When the same visitor opens the same link twice within 3 s, it counts once. A SET NX marker in the existing Upstash store enforces this.
  • The visitor is identified by the PostHog cookie, or else by an ocb_vid cookie that /go sets itself.
  • The Continue link on the confirmation page carries a single-use pass that lasts one hour.
  • A request without text/html in Accept is not counted: no real browser navigation omits it, scripts borrowing a browser user agent do.
  • A server event that PostHog fails to receive (network error, 429, 5xx) is sent once more with the same uuid.
  • No link uses /go yet.

Why: the browser listener misses middle clicks, the context menu, readers without JavaScript and content blockers, so outbound clicks are undercounted. Without the repeat filter, a double click or a reload would also count twice. Without the pass, two cases would never be counted: in-app browsers that reload links themselves, and proxies that strip Sec-Fetch-Site.

Testing:

  • bun test src/, tsc, eslint.
  • A decideGo table.
  • Route tests against an in-memory store. They cover: repeats and window expiry, cookies, the store being down (repeat_checked: false), the pass (reuse, expiry, wrong destination, HEAD, prefetch, bots), rate limits, scripted Accept headers and resending after a failure.

Risk:

  • Forged headers or cookies still count, at most 60 per minute per client.
  • When Upstash is down, clicks are counted and flagged as unchecked.
  • Each confirmation page costs at most 2 Redis commands.
  • Only search engines get a plain uncounted 302.
  • uaFamily gains declared-bot.
  • rss and sitemap now share escapeMarkup.

Rollback: revert this PR. Nothing links to /go until #3527, and the Redis keys expire within an hour.

Server-side redirect that will count every opening of an outbound link,
including middle click, context menu, no JavaScript and blockers. No link
uses it yet: this only adds the route and outboundHref.
Cap the Referer before bareHost's regex sees it, keep declared crawlers
out of the count, and never let a failed capture block the redirect.

Tests now cover the per-client limit, the window, a non-production host,
the Continue link's escaping and the 400 page. The rss routes share
escapeMarkup with /go instead of a third copy.
Other non-browsers get the confirmation page, uncounted, so a scripted client cannot use /go as a plain open redirect.
@ether-eye
ether-eye marked this pull request as draft November 5, 2026 14:44
The search engine families live in one table that uaFamily and decideGo share. The host helpers move out of utm.ts. Three tests that repeated the route table or an escaping case are removed.
A second opening of the same link by the same visitor within three
seconds is dropped through a SET NX marker in the existing store
(Upstash on Vercel). When the store is unavailable the click is still
counted and flagged repeat_checked=false.

The visitor is the PostHog cookie, or else an ocb_vid cookie that /go
sets itself; events carry visitor_source and the active $session_id.

The Continue link on the confirmation page carries a single-use pass
(GETDEL, one hour), so in-app browsers that reload links themselves and
proxies that strip Sec-Fetch-Site are counted once when the reader
continues. A Referer alone proves our page only for agents too old to
send Sec-Fetch-Site.

Tested by route tests against an in-memory store: repeats, expiry,
cookies, store down, pass reuse, expiry, wrong destination, HEAD,
prefetch and bots. Rollback: revert this commit.
A browser navigation always asks for text/html. A script that only
borrows a browser user agent sends */* or a JSON accept, and could
reach the Continue code path and be counted. /go now treats a request
without text/html in Accept like any other non-browser client: a
direct link, no count, no code.

Server events were dropped on the first PostHog failure. They now
carry a uuid and are sent once more after a network error, a 429 or
a 5xx, so a transient failure no longer loses a click.
@ether-eye
ether-eye marked this pull request as ready for review November 6, 2026 14:00
@ether-eye
ether-eye merged commit 60cb48c into dev Nov 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant