Skip to content

v0.104.5

@abelanger5 abelanger5 tagged this 21 Aug 15:46
* Scope consent defaults to the visitor's region on docs and the app

Both properties asked every visitor for consent regardless of where they
were, so anyone outside the EEA who never clicked the banner was counted
cookielessly — a measurement loss rather than a privacy win, given that is
most of the traffic.

Defaults are now denied in the EEA, the UK and Switzerland and granted
elsewhere, with an explicit choice always winning. The docs site resolves
the country in proxy.ts from `x-vercel-ip-country` and publishes it as
`ht_region` on `.hatchet.run`.

cloud.hatchet.run cannot resolve a country of its own — it sits behind no
edge that provides one — so it reads that cookie and falls back to
"restricted" when it is absent, which is exactly how it behaved before
regions existed. A miss is never a regression, only an unrealised gain.

The stored choice moves to an `ht_consent` cookie on the same shared domain,
so one decision now covers hatchet.run, docs.hatchet.run and
cloud.hatchet.run instead of each origin asking separately. The docs banner
previously wrote three overlapping stores — a `cookieConsent` cookie dated
to the year 9999, a localStorage key, and React state — which is collapsed
to the one cookie, with the old values migrated on first load.

Tenant-level `analyticsOptOut` still wins over everything in the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybVM54Fq1MSsNCLeknoBk

* Record the campaign that produced a visit, gated on consent

The ad click that brings someone to Hatchet almost never lands on the page
they sign up from: they arrive on docs.hatchet.run from an ad, read for a
while, and create an account on cloud.hatchet.run days later. Without
somewhere durable to keep it, that click is unattributable by the time it
matters.

`ht_attr` holds it in a first-party cookie on `.hatchet.run`, which the
control plane reads back at signup. The docs site writes it at the edge in
proxy.ts, where the landing query string is; the app writes it at startup
for anyone who lands there directly.

It is a marketing cookie with a 90-day life, so it follows consent rather
than being written on arrival: nothing for an unaccepted visitor in a
consent-required region, nothing for a visit carrying no campaign at all,
and a decline deletes it. First touch wins.

Every field is re-validated server-side when it is read, so nothing here is
trusted; this end only decides what to offer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybVM54Fq1MSsNCLeknoBk

* Stop capturing attribution in the app; keep it honouring consent

Two separate things were bundled here and only one earns its place.

The attribution capture goes. Every ad lands on hatchet.run, so a visitor
arriving directly at cloud.hatchet.run with a campaign on the URL is a case
that barely exists, and when it does the marketing site and the docs have
already written the shared cookie. The app was writing a cookie nobody was
waiting for.

Reading consent stays, because without it the shared decision is a fiction.
Today the app calls opt_in_capturing() unconditionally once a tenant loads,
so a user who clicks Decline on hatchet.run and then signs up is opted back
in on cloud.hatchet.run. An `ht_consent` cookie on `.hatchet.run` is only
worth having if the product actually reads it.

That is not really about analytics on the login page, which is what the
unauthenticated surface here amounts to. It is about product analytics for
signed-in users respecting a choice made somewhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PybVM54Fq1MSsNCLeknoBk

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Assets 2
Loading