Send docs visitors to hanzo.ai, not hanzo.id, and count the plan click

The landing's "Sign up free" opened hanzo.id/signup, and "Sign in" opened
/login, whose authorize step sent the reader to hanzo.id too. No surface
sends a visitor there. Sign-in is hanzo.ai/login now (the /login address is
a redirect rule), sign-out clears the session in this browser instead of
carrying the reader to the issuer's end-session page, and the conversion
CTA reads "Choose plan" and opens hanzo.ai/pricing.

The click fires plan_clicked {source: 'docs'} on the client that counts the
page views, named from the @hanzo/events catalog that @hanzo/docs-analytics
now re-exports.

The ingest key comes from @hanzo/event's keyring, resolved from the page's
host, rather than a KMS value inlined at build. docs.hanzo.ai resolves the
hanzo org's key, the one the rest of the funnel reports under, and a sibling
docs site on a host no brand claims reports nothing rather than filing its
visitors under hanzo. The deploy resolves the same key, asks /v1/event
whether it names a project, and greps the export for it.
This commit is contained in:
2026-09-29 21:44:35 -07:00
parent 8e7e92d634
commit bb38ff54ad
10 changed files with 73 additions and 101 deletions
+13 -35
View File
@@ -63,42 +63,23 @@ jobs:
with:
bun-version: 1.3.11
# The ingest key is public (it ships in the bundle) and lives in KMS so it
# rotates without a commit. Login and read are retried on a lost answer
# (000/429/5xx) and not on a refusal.
- run: pnpm install --frozen-lockfile
# The ingest key is the hanzo org's publishable key from @hanzo/event's
# keyring, which the page resolves from its own host (packages/analytics).
# This resolves it the same way for docs.hanzo.ai. A key that names a
# deleted project answers 403 while the site looks perfect, so ask the
# endpoint before building: a lost answer (000/429/5xx) warns, a refusal
# stops the run.
- name: ingest key
env:
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
run: |
set -euo pipefail
KMS=https://api.hanzo.ai
[ -n "${KMS_CLIENT_ID:-}" ] && [ -n "${KMS_CLIENT_SECRET:-}" ] || {
echo "::error::KMS_CLIENT_ID/KMS_CLIENT_SECRET are unset on this forge org"; exit 1; }
ask() { # ask <outfile> <curl args...> -> status code
local out="$1"; shift; local code=""
for wait in 0 5 15 45; do
[ "$wait" = 0 ] || sleep "$wait"
code="$(curl -s -o "$out" -w '%{http_code}' --max-time 30 "$@")" || code=000
case "$code" in 000|429|5*) continue ;; *) break ;; esac
done
printf '%s' "$code"
}
code="$(ask /tmp/login.json "$KMS/v1/kms/auth/login" -H 'Content-Type: application/json' \
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}")"
[ "$code" = 200 ] || { echo "::error::KMS login answered $code"; exit 1; }
token="$(jq -r '.accessToken // empty' /tmp/login.json)"
[ -n "$token" ] || { echo "::error::KMS login returned no accessToken"; exit 1; }
code="$(ask /tmp/key.json "$KMS/v1/kms/secrets/deploy/PUBLISHABLE_KEY?env=prod" \
-H "Authorization: Bearer $token")"
[ "$code" = 200 ] || { echo "::error::reading deploy/PUBLISHABLE_KEY answered $code (403: this client may not read it; 404: not sealed)"; exit 1; }
key="$(jq -r '.value // empty' /tmp/key.json)"
rm -f /tmp/login.json /tmp/key.json
key="$(cd packages/analytics && node --input-type=module \
-e "import { keyFor } from '@hanzo/event'; process.stdout.write(keyFor('docs.hanzo.ai') ?? '')")"
case "$key" in
pk-*) ;;
*) echo "::error::deploy/PUBLISHABLE_KEY is empty or not a pk- key"; exit 1 ;;
*) echo "::error::@hanzo/event resolves no pk- key for docs.hanzo.ai"; exit 1 ;;
esac
# A well-formed key can still name a deleted project: ask the endpoint.
code="$(curl -s -o /tmp/ingest.json -w '%{http_code}' --max-time 20 \
-X POST "https://api.hanzo.ai/v1/event?ingest_key=$key" \
-H 'content-type: application/json' -d '{"batch":[]}')" || code=000
@@ -107,10 +88,7 @@ jobs:
000|429|5*) echo "::warning::ingest endpoint answered $code; key unverified" ;;
*) echo "::error::the ingest key is refused ($code): $(cat /tmp/ingest.json)"; exit 1 ;;
esac
echo "::add-mask::$key"
echo "NEXT_PUBLIC_PUBLISHABLE_KEY=$key" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
echo "INGEST_KEY=$key" >> "$GITHUB_ENV"
# The workspace packages first, on their own, so a failure here is not
# read as a failure of the site. `docs^...` is turbo's word for "what docs
@@ -159,7 +137,7 @@ jobs:
run: |
set -eu
sh scripts/check-export.sh apps/docs/out
grep -rqF "$NEXT_PUBLIC_PUBLISHABLE_KEY" apps/docs/out \
grep -rqF "$INGEST_KEY" apps/docs/out \
|| { echo "::error::the ingest key is not in the export"; exit 1; }
# The slug is the host's name, as hanzo-ai is hanzo.ai's and cloud-hanzo-ai
+14
View File
@@ -137,6 +137,20 @@ zt-docs) predate the unified `content/docs/` model — migrate their content int
`content/docs/<section>/` (or a `hanzo-docs/<team>` submodule) then archive the
app.
## Funnel
- **No page sends a visitor to hanzo.id.** Sign-in is `https://hanzo.ai/login`
(`components/account.tsx`; `/login` is a rule in `public/_redirects`).
Sign-out clears the session in this browser and navigates nowhere.
- **The conversion CTA reads "Choose plan"** and opens `https://hanzo.ai/pricing`,
firing `plan_clicked` `{ source: 'docs' }` (`components/landing.tsx`).
- **Events are catalog names only** — `EVENTS` from @hanzo/events, re-exported
by `@hanzo/docs-analytics`. That package sends page views (first load and each
route change) to `api.hanzo.ai/v1/event` with the key `@hanzo/event`'s keyring
resolves from the page's host: `docs.hanzo.ai` is the hanzo org's, `localhost`
resolves none. The cold image lane's `PUBLISHABLE_KEY` build arg is no longer
read by any app.
## Build guards, and why prose needs them
`scripts/pre-build.ts` ends with checks that fail the build. The first two exist for
+6 -6
View File
@@ -58,17 +58,17 @@ completing a deployment reconciles the prefix against the manifest it sends.
## What the job proves before it publishes
- **The ingest key resolves.** `deploy/PUBLISHABLE_KEY` (env `prod`) comes from
KMS — the org's Actions secrets carry only `KMS_CLIENT_ID`/`KMS_CLIENT_SECRET`
— and is POSTed to `/v1/event` before the build. A key that is present but
names a deleted project answers 403, and a site built on it loses every
pageview while looking perfect.
- **The ingest key resolves.** The page sends the hanzo org's publishable key
from `@hanzo/event`'s keyring, resolved from its host (`packages/analytics`).
The job resolves the same key for `docs.hanzo.ai` and POSTs it to `/v1/event`
before the build. A key that names a deleted project answers 403, and a site
built on it loses every pageview while looking perfect.
- **The export is a site.** `scripts/check-export.sh` refuses an export without
`index.html`, `docs/index.html`, 50+ pages, a rendered nav, and every path in
`apps/docs/export.require` — the sections that vanish without breaking anything
else (`docs/studio/` is a submodule, `docs/openapi/` and `docs/mcp-tools/` are
generated).
- **The key is in the bytes.** A build-time env that never reached the bundle is
- **The key is in the bytes.** A keyring that never reached the bundle is
invisible everywhere except the warehouse, so the export is grepped for it.
- **Every link resolves.** `build:post` walks the export, follows every anchor
through the serving ladder (`/docs` -> `docs.html` -> `docs/index.html`) and
+1 -1
View File
@@ -24,7 +24,7 @@ export default function CallbackPage() {
title="Sign in failed"
note={error}
action={
<Action tone="loud" render="a" href="/login" mt={6} rounded={999}>
<Action tone="loud" render="a" href="https://hanzo.ai/login" mt={6} rounded={999}>
Try again
</Action>
}
-38
View File
@@ -1,38 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Action } from '@/components/action'
import { Status } from '@/components/status'
import { iam } from '@/lib/iam'
export default function LoginPage() {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
// Already signed in — including in ANOTHER tab, now that the session is not
// scoped to this one.
if (iam().getAccessToken()) {
window.location.href = '/docs'
return
}
iam()
.signinRedirect()
.catch(() => setError('Failed to load authentication. Please try again.'))
}, [])
if (error)
return (
<Status
title="Sign in error"
note={error}
action={
<Action tone="loud" render="button" type="button" onPress={() => window.location.reload()} mt={6} rounded={999}>
Try again
</Action>
}
/>
)
return <Status note="Redirecting to sign in…" />
}
+10 -11
View File
@@ -1,6 +1,5 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { Text, XStack } from '@hanzo/gui';
import { ChevronsUpDown } from '@hanzogui/lucide-icons-2';
@@ -10,6 +9,8 @@ import { currentUser, iam, type DocsUser } from '@/lib/iam';
import { muted } from '@/lib/ink';
const CONSOLE = 'https://platform.hanzo.ai';
/** Sign-in is hanzo.ai's page. No surface sends a visitor to hanzo.id. */
const LOGIN = 'https://hanzo.ai/login';
/**
* Who is signed in, asked once per page load and shared by the rail, the bar and
@@ -32,14 +33,12 @@ function useUser(): [DocsUser | null, (u: DocsUser | null) => void] {
}
function signOut(done: () => void) {
// The SDK owns the token store and clears every key it wrote.
iam()
.logout()
.catch(() => iam().clearTokens())
.finally(() => {
done();
window.location.reload();
});
// The session ends here, in this browser. The SDK's logout() would carry the
// reader on to the issuer's end-session page on hanzo.id, and no surface sends
// a visitor there. The SDK owns the token store and clears every key it wrote.
iam().clearTokens();
done();
window.location.reload();
}
/**
@@ -53,7 +52,7 @@ export function Account() {
if (!user)
return (
<XStack gap={8}>
<Action tone="line" flex={1} render={<Link href="/login" prefetch={false} />}>
<Action tone="line" flex={1} render="a" href={LOGIN}>
Sign in
</Action>
<Action tone="loud" flex={2} render="a" href={CONSOLE}>
@@ -113,7 +112,7 @@ export function Short() {
return (
<XStack gap={8} items="center">
<Action tone="quiet" render={<Link href="/login" prefetch={false} />} $max-sm={{ display: 'none' }}>
<Action tone="quiet" render="a" href={LOGIN} $max-sm={{ display: 'none' }}>
Sign in
</Action>
<Action tone="loud" render="a" href={CONSOLE}>
+12 -2
View File
@@ -20,6 +20,7 @@ import {
Workflow,
} from '@hanzogui/lucide-icons-2';
import { Grid } from '@hanzo/ui/grid';
import { EVENTS, useAnalytics } from '@hanzo/docs-analytics';
import { Action } from '@/components/action';
import { Agent } from '@/components/agent';
import { HeroField } from '@/components/hero-field';
@@ -144,6 +145,7 @@ function Tile({ name, spec, mono = false }: { name: string; spec: string; mono?:
}
export function Landing({ install, use }: { install: ReactNode; use: ReactNode }) {
const analytics = useAnalytics();
return (
<YStack render="main" minW={0} pb={48}>
{/* The hero, centred over its halftone. */}
@@ -410,8 +412,16 @@ export function Landing({ install, use }: { install: ReactNode; use: ReactNode }
curl hanzo.sh | sh
</Text>
<XStack flexWrap="wrap" gap={12} justify="center">
<Action tone="loud" render="a" href="https://hanzo.id/signup?redirect_uri=https://platform.hanzo.ai" height={44} px={28} rounded={999}>
Sign up free
<Action
tone="loud"
render="a"
href="https://hanzo.ai/pricing"
onPress={() => analytics.capture(EVENTS.PLAN_CLICKED, { source: 'docs' })}
height={44}
px={28}
rounded={999}
>
Choose plan
</Action>
<Action tone="line" render={<Link href="/docs" prefetch={false} />} height={44} px={28} rounded={999}>
Browse documentation
+1 -1
View File
@@ -87,7 +87,7 @@ export const onRequestPost: PagesFunction<Env> = async (context) => {
return new Response(
JSON.stringify({
error: response.status === 429
? 'Rate limit exceeded. Sign in at hanzo.id for higher limits.'
? 'Rate limit exceeded. Sign in at hanzo.ai/login for higher limits.'
: `AI API error: ${response.status}`,
detail: text,
}),
+5 -1
View File
@@ -26,9 +26,13 @@
# Tutorials were merged into Guides.
/tutorials /docs/guides 301
# Sign-in is hanzo.ai's page; this site no longer runs its own sign-in, whose
# authorize step sent the reader to hanzo.id.
/login https://hanzo.ai/login 301
# Top-level convenience aliases. docs.hanzo.ai is already the docs subdomain, so
# bare product paths resolve to their canonical page under /docs. Explicit list
# only — never a catch-all, which would shadow /, /blog, /showcase, /login.
# only — never a catch-all, which would shadow /, /blog, /showcase.
#
# /cloud is where hanzo.id's Cloud card and every "Docs" button beside the
# product send a reader, and Hanzo Cloud is not a section of this site — it is
+11 -6
View File
@@ -1,9 +1,12 @@
'use client';
import { keyForPage } from '@hanzo/event';
import { AnalyticsProvider, usePageview } from '@hanzo/event/react';
/** The client, for a page that sends an event of its own. */
/** The client, for a page that sends an event of its own, and the catalog
* (@hanzo/events) it takes the event's name from. */
export { useAnalytics } from '@hanzo/event/react';
export { EVENTS } from '@hanzo/event';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
@@ -13,11 +16,13 @@ import type { ReactNode } from 'react';
* tenant server-side from the publishable ingest key. */
const HOST = 'https://api.hanzo.ai';
/** Publishable ingest key (pk_…), minted per org via POST /v1/ingest/keys. A docs
* site is read-only and logged out, so no bearer can ride the request — this
* write-only, bundle-safe key IS how anonymous pageviews and errors resolve to an
* org. Unset → events are best-effort and dropped at the edge. */
const INGEST_KEY = process.env.NEXT_PUBLIC_PUBLISHABLE_KEY?.trim() || undefined;
/** Publishable ingest key: the org's, from the keyring @hanzo/event carries,
* resolved from the host the page is served on — docs.hanzo.ai is the hanzo
* org's. A docs site is read-only and logged out, so no bearer can ride the
* request — this write-only, bundle-safe key IS how anonymous pageviews and
* errors resolve to an org. A host no brand claims gets none and reports
* nothing, rather than filing its visitors under another org. */
const INGEST_KEY = keyForPage();
/** Honor an explicit browser opt-out (Global Privacy Control, then legacy DNT).
* SSR (no navigator) defaults to consented; the browser reads the real signal on