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
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user