analytics: the hosted tag runs the one identity chain instead of a third copy
Hanzo CI/CD / cicd (push) Successful in 12s
CI/CD / gate (push) Successful in 13s
CI/CD / containment (push) Successful in 1m52s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s

/v1/event.js resolved the anonymous id itself, out of localStorage alone. That is
ORIGIN-scoped, so it never saw the cookie @hanzo/event shares across *.hanzo.ai
and never saw the id hz.js had already left behind under its own key: an origin
carrying only this tag was a separate population, and one visitor was two or
three people depending on which snippet a surface happened to load.

anon.js is that chain, vendored BYTE-FOR-BYTE from @hanzo/event
(hanzoai/ui pkgs/event/src/anon.js), and tag.go now serves it with the tag as one
asset inside one wrapper — so the door holds no second implementation, and
neither half leaves a name on the page it is pasted into. tag.js calls hzAnonId
and no longer names an identity key at all.

Resolution is cookie · localStorage hz_anon_id · localStorage hz_id · in-memory ·
mint: every id already in a browser is adopted, and only a browser holding none
is given a new one.

TestTagBehavior now runs the COMPOSED asset rather than tag.js, because the chain
is half of what ships and the other half no longer runs alone.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-08-05 01:31:20 -07:00
parent f94c232ed2
commit 1720eb20b5
5 changed files with 363 additions and 33 deletions
+199
View File
@@ -0,0 +1,199 @@
/*! anon.js — THE anonymous-identity chain. ONE implementation, three distributions.
*
* One browser is ONE person on every Hanzo surface, whichever client a page
* happens to have loaded. There were three implementations writing TWO keys —
* `hz_anon_id` (the npm client, the hosted tag) and `hz_id` (hz.js) — so the same
* visitor was several people depending on which snippet the surface shipped.
*
* The three call sites:
* 1. src/storage.ts — the bundled npm client; IMPORTS this file.
* 2. hz.js — the no-build script tag; INLINES the marked region.
* 3. hanzoai/cloud apps/analytics/tag.js — the tag the door hosts at
* /v1/event.js; vendors this file and its tag.go serves the marked region
* with the tag as one asset, so the door holds no second copy either.
*
* (2) and (3) have no bundler and cannot import anything, which is why the chain
* lives in a file that is plain ES5 rather than in a .ts: the region between the
* BEGIN and END markers is COPIED VERBATIM, and src/anon.test.ts fails if hz.js's
* copy is so much as a byte different. Keep the region ES5, dependency-free,
* `hz`-prefixed (it is spliced into other people's scopes) and unformatted — a
* reformat of one copy is a diff against the other.
*/
/* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
/** The ONE anonymous-id key, on every surface and in every distribution. */
var HZ_ANON_KEY = 'hz_anon_id'
/** hz.js used to write `hz_id` — a SECOND identity space, so the one-paste tag
* and the npm client were two different people on one page. It is READ and never
* written: an id already in the wild is ADOPTED into the shared identity, because
* minting over one detaches a returning visitor from their own history. */
var HZ_ANON_LEGACY_KEY = 'hz_id'
/** The registrable domain the cookie is scoped to, so docs, cloud, console,
* studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
* ORIGIN-scoped, which is what made one journey arrive as several strangers. */
var HZ_ANON_DOMAIN = 'hanzo.ai'
/** Two years, rewritten on every read, so the cookie rolls forward with the
* visitor instead of expiring two years after first touch. Safari caps a
* SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
* what keeps a returning Safari visitor: each read re-arms the 7-day window. */
var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
/** Last resort for a browser that refuses cookies AND localStorage: without it
* every event in a page load would mint an id of its own. */
var hzAnonMemo
/**
* hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
*
* It has to be v7, and this is the only minter any distribution may use. The
* session rollups on the event plane derive a session's start instant FROM THE ID
* and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
* is not merely unordered there — it is DISCARDED, silently, and the rollup stays
* empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
*/
function hzUuidv7(now) {
var b = new Uint8Array(16)
var i
var c = typeof crypto !== 'undefined' ? crypto : undefined
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
var t = Math.floor(now === undefined ? Date.now() : now)
for (i = 5; i >= 0; i--) {
b[i] = t % 256
t = Math.floor(t / 256)
}
b[6] = 0x70 | (b[6] & 0x0f) // version 7
b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
var h = ''
for (i = 0; i < 16; i++) {
h += (b[i] + 0x100).toString(16).slice(1)
if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
}
return h
}
/** The cookie jar, or null wherever there is no document to read one from. */
function hzAnonJar() {
try {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
return document
} catch (e) {
return null // sandboxed frame with an opaque origin
}
}
/** localStorage, or null when the browser refuses it (Safari private mode). */
function hzAnonStore() {
try {
if (typeof window === 'undefined' || !window.localStorage) return null
return window.localStorage
} catch (e) {
return null
}
}
/** One stored value, or '' — a jar can read as well as refuse to. */
function hzAnonItem(store, name) {
try {
return (store && store.getItem(name)) || ''
} catch (e) {
return ''
}
}
/** The value of cookie `name`, or ''. */
function hzAnonCookie(name) {
var d = hzAnonJar()
if (!d) return ''
var parts = d.cookie.split(';')
for (var i = 0; i < parts.length; i++) {
var eq = parts[i].indexOf('=')
if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
var v = parts[i].slice(eq + 1).trim()
if (!v) continue
try {
return decodeURIComponent(v)
} catch (e) {
return v // not percent-encoded — take it as written
}
}
return ''
}
/** Writes `name` on the registrable domain, for as long as the browser allows. */
function hzAnonWrite(name, value) {
var d = hzAnonJar()
if (!d) return
var host = ''
var secure = false
try {
if (typeof window !== 'undefined' && window.location) {
host = window.location.hostname || ''
// A Secure cookie is refused outright by a non-secure origin, which would
// strand http://localhost dev on the localStorage path.
secure = window.location.protocol === 'https:'
}
} catch (e) {
/* location unreachable — write a host-only, non-secure cookie */
}
// encodeURIComponent leaves a UUID byte-identical while making any value that is
// not one unable to forge a `;` and inject an attribute.
var c = name + '=' + encodeURIComponent(value)
c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
// Off hanzo.ai (localhost, previews, other registrable domains) the attribute
// would be rejected and the whole cookie dropped, so it stays host-only there.
// Prefixing both sides with '.' matches the domain itself and its subdomains
// while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
c += '; Domain=' + HZ_ANON_DOMAIN
}
if (secure) c += '; Secure'
try {
d.cookie = c
} catch (e) {
/* cookies refused — localStorage still carries the id */
}
}
/**
* hzAnonId returns the stable anonymous id for this browser, '' during SSR.
*
* Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
* only a browser holding none of them is given a new one:
*
* cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
*
* Minting over an id resets a returning visitor and detaches them from their own
* history, so the order is the migration: the cookie is the shared home, the two
* localStorage keys are what the three implementations wrote before it existed,
* and each is read until nothing is left to adopt.
*
* localStorage keeps being written, so a rollback finds everyone where it left
* them, and a browser that refuses cookies still holds one id per origin.
*/
function hzAnonId() {
if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
var s = hzAnonStore()
var id =
hzAnonCookie(HZ_ANON_KEY) ||
hzAnonItem(s, HZ_ANON_KEY) ||
hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
hzAnonMemo ||
hzUuidv7()
hzAnonMemo = id
hzAnonWrite(HZ_ANON_KEY, id)
try {
if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
} catch (e) {
/* quota exhausted, or a private-mode jar that reads but refuses writes */
}
return id
}
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
export { hzAnonId, hzUuidv7 }
+49 -3
View File
@@ -36,9 +36,11 @@
package analytics
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"fmt"
"net/http"
"github.com/hanzoai/cloud/openapi"
@@ -47,10 +49,54 @@ import (
//go:embed tag.js
var tagJS []byte
// tagETag is the tag's content hash, computed once. A tag is fetched on every
// anonJS is the anonymous-identity chain, VENDORED BYTE-FOR-BYTE from
// @hanzo/event (github.com/hanzoai/ui, pkgs/event/src/anon.js). It is the one
// implementation of who a browser is, and the npm client and hz.js run the same
// text: a page carrying any two Hanzo clients has to resolve to ONE person, and
// it did not — this tag read localStorage alone under a key hz.js never wrote.
//
// Vendored rather than restated because a fourth restatement is how the split
// happened in the first place. To resync:
//
// curl -fsSL https://unpkg.com/@hanzo/event/src/anon.js -o apps/analytics/anon.js
//
// and TestTagServesOneIdentityChain holds the seam: this file must carry the
// marked region, and tag.js must not name an identity key of its own.
//
//go:embed anon.js
var anonJS []byte
// tagAsset is what /v1/event.js actually serves: the shared chain, then the tag,
// inside ONE wrapper so neither half leaves a name on the page it is pasted into
// (the chain is written to be spliced into other people's scopes, and hz.js
// splices it into its own). tag.js is itself an IIFE, so it simply nests, and
// document.currentScript still resolves — the script is executing.
var tagAsset = func() []byte {
const begin, end = "/* ── BEGIN hz anon chain", "/* ── END hz anon chain"
b := bytes.Index(anonJS, []byte(begin))
e := bytes.Index(anonJS, []byte(end))
if b < 0 || e < b {
// Unreachable with an intact embed, and a silent miss would ship a tag
// whose every event carries an undefined identity.
panic(fmt.Sprintf("analytics: anon.js carries no shared chain (begin=%d end=%d)", b, e))
}
nl := bytes.IndexByte(anonJS[e:], '\n')
if nl < 0 {
panic("analytics: anon.js END marker is not a whole line")
}
var out bytes.Buffer
out.WriteString(";(function () {\n")
out.Write(anonJS[b : e+nl+1])
out.WriteByte('\n')
out.Write(tagJS)
out.WriteString("\n})()\n")
return out.Bytes()
}()
// tagETag is the asset's content hash, computed once. A tag is fetched on every
// cold page load in the fleet, so the 304 is the common answer, not the rare one.
var tagETag = func() string {
sum := sha256.Sum256(tagJS)
sum := sha256.Sum256(tagAsset)
return `"` + hex.EncodeToString(sum[:16]) + `"`
}()
@@ -98,5 +144,5 @@ func serveTag(w http.ResponseWriter, r *http.Request) {
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(tagJS)
_, _ = w.Write(tagAsset)
}
+13 -16
View File
@@ -8,6 +8,10 @@
* NO KEY ⇒ INERT. A keyless beacon is accepted 200 into $public, a reserved
* tenant the owning org cannot read — a silence that looks like success. Sending
* nothing is the honest failure, and it is the one this tag picks.
*
* `hzAnonId` is not defined here: it comes from anon.js, the shared identity
* chain vendored from @hanzo/event, which tag.go serves ahead of this file
* inside one wrapper. Both halves are the asset; neither runs alone.
*/
(function () {
if (window.__hanzoEvent) return
@@ -27,10 +31,14 @@
var url = src.origin + '/v1/event'
var product = (el.getAttribute('data-product') || '').trim()
// Identity uses the SAME storage keys and 30-minute session TTL as
// @hanzo/event (ui/pkgs/event/src/storage.ts). A page carrying both clients
// resolves to one person, not two.
var ANON = 'hz_anon_id'
// Identity is hzAnonId, the ONE chain — anon.js, vendored verbatim from
// @hanzo/event and served ahead of this file by tag.go. Resolving it here as
// well is what made an origin carrying only this tag a separate population:
// this read localStorage alone, so it never saw the cookie the other two
// clients share and never adopted the id hz.js left behind.
//
// The session keeps its own resolution: a session is deliberately origin-local
// and 30-minute idle-bounded, and nothing about it crosses a surface.
var SESSION = 'hz_session'
var SESSION_TTL = 30 * 60 * 1000
@@ -47,17 +55,6 @@
} catch (e) {} // Safari private mode / blocked storage
}
function anonId() {
var s = store()
if (!s) return ''
var v = s.getItem(ANON)
if (!v) {
v = uid()
s.setItem(ANON, v)
}
return v
}
function sessionId() {
var s = store()
if (!s) return ''
@@ -109,7 +106,7 @@
// (event.fact.host DEFAULT domain(url)), so an event without one is a row
// nobody can attribute to a site.
function push(ev) {
var anon = anonId()
var anon = hzAnonId()
ev.messageId = uid()
ev.timestamp = new Date().toISOString()
ev.distinctId = person || anon
+40 -2
View File
@@ -15,9 +15,12 @@
package analytics
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
@@ -62,20 +65,55 @@ func TestServeTagNotModified(t *testing.T) {
// The tag carries no secret of ours: the key belongs to the PAGE. A literal key
// baked into the served asset would ship one tenant's credential to every other.
func TestTagCarriesNoKey(t *testing.T) {
if i := strings.Index(string(tagJS), "pk-live-"); i >= 0 {
if i := strings.Index(string(tagAsset), "pk-live-"); i >= 0 {
t.Fatalf("tag embeds a literal publishable key at offset %d", i)
}
}
// Who a browser is has ONE implementation, and this tag is not allowed to be a
// second one. It used to resolve the anonymous id itself, out of localStorage
// alone, so an origin carrying only this tag never saw the cookie the other two
// Hanzo clients share and never adopted the id hz.js had already left there —
// one visitor, counted as two people, decided by which snippet a page loaded.
func TestTagServesOneIdentityChain(t *testing.T) {
if !bytes.Contains(anonJS, []byte("BEGIN hz anon chain")) {
t.Fatal("anon.js carries no shared chain: resync it from @hanzo/event")
}
// The composition is the asset. Neither half is served alone, so the tag can
// call a function it does not define — but only because this holds.
if n := bytes.Count(tagAsset, []byte("function hzAnonId(")); n != 1 {
t.Errorf("served asset defines hzAnonId %d times, want exactly 1", n)
}
if !bytes.Contains(tagAsset, []byte("hzAnonId()")) {
t.Error("served asset never calls the shared chain")
}
// A snippet that spells a key has an opinion about identity, and there is one
// opinion now. Both names may appear only inside the vendored chain.
for _, key := range []string{"'hz_anon_id'", "'hz_id'"} {
if bytes.Contains(tagJS, []byte(key)) {
t.Errorf("tag.js names %s: identity belongs to anon.js alone", key)
}
}
}
// TestTagBehavior runs the tag itself (tag_test.js). Its invariants — above all
// "no key ⇒ inert" — are behavior, and asserting on source text would prove only
// that the source contains a string.
//
// It runs the COMPOSED asset, not tag.js: the identity chain is half of what
// ships, and a harness that loaded only the other half would test a file that no
// browser ever receives (and, since tag.js alone cannot resolve an id, would not
// even run).
func TestTagBehavior(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node not installed; tag behavior unverified in this environment")
}
out, err := exec.Command(node, "tag_test.js", "tag.js").CombinedOutput()
asset := filepath.Join(t.TempDir(), "tag.js")
if err := os.WriteFile(asset, tagAsset, 0o600); err != nil {
t.Fatalf("write asset: %v", err)
}
out, err := exec.Command(node, "tag_test.js", asset).CombinedOutput()
if err != nil {
t.Fatalf("tag behavior: %v\n%s", err, out)
}
+62 -12
View File
@@ -12,10 +12,13 @@ const source = fs.readFileSync(process.argv[2], 'utf8')
// run loads the tag into a fresh sandbox and returns what it sent.
// attrs are the data-* attributes on the <script> element.
// opts.storage seeds localStorage, opts.jar seeds the cookies — the two places a
// browser may already be carrying an anonymous id when this tag arrives.
function run(attrs, opts = {}) {
const sent = { fetch: [], beacon: [] }
const listeners = {}
const storage = new Map()
const storage = new Map(Object.entries(opts.storage || {}))
const jar = new Map(Object.entries(opts.jar || {}))
const script = {
src: opts.src || 'https://api.hanzo.ai/v1/event.js',
@@ -38,9 +41,21 @@ function run(attrs, opts = {}) {
document: {
currentScript: script,
referrer: '',
visibilityState: 'visible'
visibilityState: 'visible',
// A real jar. The anonymous id lives in a cookie so it can outlive an
// ORIGIN; a stub without one would leave the whole chain unexercised.
get cookie() {
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
},
set cookie(raw) {
const first = raw.split(';')[0]
const eq = first.indexOf('=')
if (eq > 0) jar.set(first.slice(0, eq).trim(), first.slice(eq + 1).trim())
}
},
location: { href: 'https://hanzo.team/', pathname: '/' },
// hanzo.team is not hanzo.ai, so the chain writes a host-only cookie here —
// the Domain= attribute would be rejected and the cookie dropped outright.
location: { href: 'https://hanzo.team/', pathname: '/', hostname: 'hanzo.team', protocol: 'https:' },
history: { pushState() {}, replaceState() {} },
navigator: {
sendBeacon: opts.noBeacon
@@ -56,7 +71,13 @@ function run(attrs, opts = {}) {
}
sandbox.window = sandbox
vm.runInNewContext(source, sandbox)
return { sent, sandbox, storage, fire: (n, e) => (listeners[n] || []).forEach((f) => f(e)) }
return { sent, sandbox, storage, jar, fire: (n, e) => (listeners[n] || []).forEach((f) => f(e)) }
}
/** The anonymous id on the first event of the first transmission. */
function anonOf(r) {
const body = r.sent.beacon.length ? r.sent.beacon[0].body : r.sent.fetch[0].init.body
return JSON.parse(body).batch[0].anonymousId
}
// 1. NO KEY ⇒ INERT. The whole reason the tag exists: a keyless beacon is
@@ -115,15 +136,44 @@ function run(attrs, opts = {}) {
assert.strictEqual(r.sent.beacon.length, 1, 'src ?key= is honored')
}
// 6. Identity uses @hanzo/event's storage keys, so a page carrying both clients
// is one person and not two.
// 6. THE COOKIE WINS. Identity is hzAnonId — the one chain, vendored from
// @hanzo/event and served ahead of this file — so a browser that already
// carries an id arrives as the person it already is. This tag used to read
// localStorage alone, which is ORIGIN-scoped: it could not see the shared
// cookie, so an origin carrying only this tag was its own population.
{
const r = run({ 'data-key': 'pk-live-abc' })
assert.ok(r.storage.has('hz_anon_id'), 'hz_anon_id')
assert.ok(r.storage.has('hz_session'), 'hz_session')
const id = '01920000-0000-7000-8000-0000000000ee'
const r = run({ 'data-key': 'pk-live-abc' }, { jar: { hz_anon_id: id } })
r.fire('pagehide')
assert.strictEqual(anonOf(r), id, 'the cookie is the identity')
}
// 7. SPA navigation is a pageview: pushState fires no event, so history is
// 7. AN EXISTING hz_id IS ADOPTED, NOT ORPHANED. hz.js minted into a key of its
// own, so browsers in the wild carry one. Minting over it would detach a
// returning visitor from their own history.
{
const legacy = '01920000-0000-7000-8000-0000000000ff'
const r = run({ 'data-key': 'pk-live-abc' }, { storage: { hz_id: legacy } })
r.fire('pagehide')
assert.strictEqual(anonOf(r), legacy, 'hz.js legacy id adopted')
assert.strictEqual(r.jar.get('hz_anon_id'), legacy, 'carried onto the shared key')
assert.strictEqual(r.storage.get('hz_anon_id'), legacy)
}
// 8. A browser carrying nothing is given ONE id, in the durable place. The
// session stays origin-local and in localStorage — deliberately unchanged.
{
const r = run({ 'data-key': 'pk-live-abc' })
r.fire('pagehide')
const id = anonOf(r)
assert.ok(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab]/.test(id), 'v7 anon id: ' + id)
assert.strictEqual(r.jar.get('hz_anon_id'), id, 'the cookie outlives the origin')
assert.strictEqual(r.storage.get('hz_anon_id'), id)
assert.ok(r.storage.has('hz_session'), 'hz_session')
assert.ok(!r.jar.has('hz_session'), 'a session is not shared across surfaces')
}
// 9. SPA navigation is a pageview: pushState fires no event, so history is
// patched. A repeat of the same href is not a second pageview.
{
const r = run({ 'data-key': 'pk-live-abc' })
@@ -137,7 +187,7 @@ function run(attrs, opts = {}) {
assert.strictEqual(batch[1].path, '/inbox')
}
// 8. An uncaught error is captured as a typed error event.
// 10. An uncaught error is captured as a typed error event.
{
const r = run({ 'data-key': 'pk-live-abc' })
r.fire('error', { error: new Error('boom') })
@@ -149,4 +199,4 @@ function run(attrs, opts = {}) {
assert.strictEqual(err.error.handled, false)
}
console.log('tag.js: 8/8 behavioral checks passed')
console.log('tag.js: 10/10 behavioral checks passed')