@kharon-labs/browser 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/index.cjs +84 -10
- package/dist/index.d.ts +116 -4
- package/dist/index.js +84 -10
- package/dist/tracker.cjs +66 -8
- package/dist/tracker.d.ts +36 -1
- package/dist/tracker.js +66 -8
- package/dist/v1.js +1 -1
- package/package.json +1 -6
package/README.md
CHANGED
|
@@ -8,7 +8,9 @@ then measures it against a holdout.
|
|
|
8
8
|
npm install @kharon-labs/browser
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
On a site without a bundler, use the script-tag build at `https://kharonlabs.com/sdk/v1.js`.
|
|
11
|
+
On a site without a bundler, use the script-tag build at `https://kharonlabs.com/sdk/v1.js`. On a
|
|
12
|
+
React site (including Next.js and React Native Web), use
|
|
13
|
+
[`@kharon-labs/react`](https://www.npmjs.com/package/@kharon-labs/react), which is built on this package.
|
|
12
14
|
|
|
13
15
|
## Plain HTML
|
|
14
16
|
|
|
@@ -44,6 +46,7 @@ conversion("demo_booked");
|
|
|
44
46
|
| `init({ key, apiBase?, consent?, timeoutMs?, pageId?, cookieDomain? })` | Resolves the page's slots once the DOM is ready and starts measuring if allowed. |
|
|
45
47
|
| `setConsent("granted" \| "denied" \| "unknown")` | Forward your consent tool's answer. Safe to call before `init`. |
|
|
46
48
|
| `conversion(goal, value?)` | Records a goal. Forms with `data-ge-goal="…"` record theirs on submit. |
|
|
49
|
+
| `signup()` | Records a `signup` conversion. Prefer `recordSignup` in `@kharon-labs/server`, which counts only signups that succeeded. |
|
|
47
50
|
| `refresh()` | Re-scans and re-resolves, e.g. after a client-side route change. |
|
|
48
51
|
|
|
49
52
|
## Slots
|
|
@@ -56,6 +59,13 @@ conversion("demo_booked");
|
|
|
56
59
|
- The markup's own text is the default. A field the variant doesn't set keeps it, and a slot the
|
|
57
60
|
console doesn't know yet keeps all of it.
|
|
58
61
|
|
|
62
|
+
## Single-page apps
|
|
63
|
+
|
|
64
|
+
The package scans the page once, when the DOM is ready. After a client-side route change that mounts
|
|
65
|
+
new slots, call `refresh()`. On React Native Web, a `dataSet={{ geSlot: "hero" }}` prop renders
|
|
66
|
+
`data-ge-slot="hero"`; React Native's types don't declare `dataSet`, so add it with a module
|
|
67
|
+
augmentation (or use `@kharon-labs/react`, which needs no DOM attributes).
|
|
68
|
+
|
|
59
69
|
## Guarantees
|
|
60
70
|
|
|
61
71
|
- **Defaults always render.** If we answer late (after `timeoutMs`, 500 ms), fail or are down, the
|
|
@@ -64,6 +74,9 @@ conversion("demo_booked");
|
|
|
64
74
|
and Switzerland, GPC honored). Until it allows storing the visitor, the package writes no cookie
|
|
65
75
|
or storage and sends no analytics.
|
|
66
76
|
- **Holdouts.** Held-out visitors see the defaults; their impressions record the variant withheld.
|
|
77
|
+
- **Acquisition.** A session's first page view records how it arrived: `utm_*` tags, which ad
|
|
78
|
+
network's click id was present (never the id), a Kharon campaign link, and the referring site's
|
|
79
|
+
host. Never the URL's path or other parameters.
|
|
67
80
|
|
|
68
81
|
The visitor id is a first-party `ge_vid` cookie on your domain (one year; Safari caps script-set
|
|
69
82
|
cookies at seven days, `@kharon-labs/server` avoids that). A denial deletes it.
|
|
@@ -72,3 +85,15 @@ cookies at seven days, `@kharon-labs/server` avoids that). A denial deletes it.
|
|
|
72
85
|
|
|
73
86
|
About 4 KB gzip, no dependencies. ES2020 browsers; without `fetch` the page keeps its defaults, and
|
|
74
87
|
without `IntersectionObserver` impressions aren't recorded.
|
|
88
|
+
|
|
89
|
+
## Your privacy policy
|
|
90
|
+
|
|
91
|
+
Adding Kharon Labs sets a first-party cookie and sends visit data to us, so your
|
|
92
|
+
privacy policy should say so. A paragraph to adapt (not legal advice):
|
|
93
|
+
|
|
94
|
+
> We use Kharon Labs to tailor parts of this website to the organization a visit comes from and to
|
|
95
|
+
> measure whether that helps. Kharon Labs processes your IP address, browser details, the page you
|
|
96
|
+
> visit and how you arrived, and infers your organization (not you personally). With your consent
|
|
97
|
+
> where required, it stores a random visitor ID in a first-party cookie (`ge_vid`) and records page
|
|
98
|
+
> views and interactions. See https://kharonlabs.com/privacy for what Kharon Labs collects and how
|
|
99
|
+
> long it keeps it.
|
package/dist/index.cjs
CHANGED
|
@@ -36,10 +36,69 @@ __export(index_exports, {
|
|
|
36
36
|
refresh: () => refresh,
|
|
37
37
|
reveal: () => reveal,
|
|
38
38
|
scanSlots: () => scanSlots,
|
|
39
|
-
setConsent: () => setConsent
|
|
39
|
+
setConsent: () => setConsent,
|
|
40
|
+
signup: () => signup
|
|
40
41
|
});
|
|
41
42
|
module.exports = __toCommonJS(index_exports);
|
|
42
43
|
|
|
44
|
+
// src/engine/analytics/types.ts
|
|
45
|
+
var SIGNUP_GOAL = "signup";
|
|
46
|
+
|
|
47
|
+
// src/engine/analytics/touch.ts
|
|
48
|
+
var MAX_TOUCH_VALUE = 100;
|
|
49
|
+
var CLICK_ID_PARAMS = {
|
|
50
|
+
gclid: "google",
|
|
51
|
+
gbraid: "google",
|
|
52
|
+
wbraid: "google",
|
|
53
|
+
msclkid: "microsoft",
|
|
54
|
+
fbclid: "meta",
|
|
55
|
+
li_fat_id: "linkedin",
|
|
56
|
+
ttclid: "tiktok",
|
|
57
|
+
twclid: "x"
|
|
58
|
+
};
|
|
59
|
+
var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
|
|
60
|
+
var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
|
|
61
|
+
var KHARON_LINK_PARAM = "ge_co";
|
|
62
|
+
function touchFromUrl(url, referrer) {
|
|
63
|
+
const touch = {};
|
|
64
|
+
let page;
|
|
65
|
+
try {
|
|
66
|
+
page = new URL(url);
|
|
67
|
+
} catch {
|
|
68
|
+
return touch;
|
|
69
|
+
}
|
|
70
|
+
for (const field of UTM_FIELDS) {
|
|
71
|
+
const value = clean(page.searchParams.get(`utm_${field}`));
|
|
72
|
+
if (value) touch[field] = value;
|
|
73
|
+
}
|
|
74
|
+
for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
|
|
75
|
+
if (page.searchParams.get(param)) {
|
|
76
|
+
touch.clickId = kind;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
|
|
81
|
+
const from = referrerHost(referrer);
|
|
82
|
+
if (from && from !== bareHost(page.hostname)) touch.referrer = from;
|
|
83
|
+
return touch;
|
|
84
|
+
}
|
|
85
|
+
function clean(value) {
|
|
86
|
+
const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
87
|
+
return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
|
|
88
|
+
}
|
|
89
|
+
function referrerHost(referrer) {
|
|
90
|
+
if (!referrer) return null;
|
|
91
|
+
try {
|
|
92
|
+
const { protocol, hostname } = new URL(referrer);
|
|
93
|
+
return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function bareHost(hostname) {
|
|
99
|
+
return hostname.toLowerCase().replace(/^www\./, "");
|
|
100
|
+
}
|
|
101
|
+
|
|
43
102
|
// src/engine/analytics/beacon.ts
|
|
44
103
|
var BEACON_VERSION = 1;
|
|
45
104
|
var MAX_BEACON_EVENTS = 50;
|
|
@@ -48,7 +107,9 @@ var SLOT_ATTRIBUTES = {
|
|
|
48
107
|
variant: "data-ge-variant",
|
|
49
108
|
counterfactual: "data-ge-counterfactual",
|
|
50
109
|
/** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
|
|
51
|
-
reason: "data-ge-reason"
|
|
110
|
+
reason: "data-ge-reason",
|
|
111
|
+
/** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
|
|
112
|
+
rung: "data-ge-rung"
|
|
52
113
|
};
|
|
53
114
|
|
|
54
115
|
// packages/browser/src/tracker.ts
|
|
@@ -67,7 +128,7 @@ function startTracker({
|
|
|
67
128
|
crossOrigin = false
|
|
68
129
|
}) {
|
|
69
130
|
const win = root.defaultView ?? window;
|
|
70
|
-
const sessionId =
|
|
131
|
+
const { id: sessionId, started } = sessionFor(win);
|
|
71
132
|
const queue = [];
|
|
72
133
|
const seenSlots = /* @__PURE__ */ new Set();
|
|
73
134
|
const push = (type, body) => {
|
|
@@ -77,7 +138,7 @@ function startTracker({
|
|
|
77
138
|
const flush = () => {
|
|
78
139
|
while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
|
|
79
140
|
};
|
|
80
|
-
push("page_view", { pageId, configVersion });
|
|
141
|
+
push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
|
|
81
142
|
const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
|
|
82
143
|
const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
|
|
83
144
|
(entries) => {
|
|
@@ -143,22 +204,23 @@ function readSlot(el) {
|
|
|
143
204
|
return {
|
|
144
205
|
slotId,
|
|
145
206
|
variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
|
|
146
|
-
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
|
|
207
|
+
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
|
|
208
|
+
...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
|
|
147
209
|
};
|
|
148
210
|
}
|
|
149
211
|
function closestSlot(target) {
|
|
150
212
|
const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
|
|
151
213
|
return el ? readSlot(el) : null;
|
|
152
214
|
}
|
|
153
|
-
function
|
|
215
|
+
function sessionFor(win) {
|
|
154
216
|
try {
|
|
155
217
|
const existing = win.sessionStorage.getItem(SESSION_KEY);
|
|
156
|
-
if (existing) return existing;
|
|
218
|
+
if (existing) return { id: existing, started: false };
|
|
157
219
|
const id = uuid(win);
|
|
158
220
|
win.sessionStorage.setItem(SESSION_KEY, id);
|
|
159
|
-
return id;
|
|
221
|
+
return { id, started: true };
|
|
160
222
|
} catch {
|
|
161
|
-
return uuid(win);
|
|
223
|
+
return { id: uuid(win), started: true };
|
|
162
224
|
}
|
|
163
225
|
}
|
|
164
226
|
function uuid(win) {
|
|
@@ -204,6 +266,7 @@ function hashSlotDefaults(content) {
|
|
|
204
266
|
var RESOLVE_PATH = "/api/v1/resolve";
|
|
205
267
|
var COLLECT_V2_PATH = "/api/v1/collect";
|
|
206
268
|
var REASONS = ["matched", "default", "holdout"];
|
|
269
|
+
var RUNGS = ["exact", "near", "segment", "default"];
|
|
207
270
|
function parseResolveResponse(body) {
|
|
208
271
|
if (!isRecord(body) || body.v !== RESOLVE_API_VERSION || !isRecord(body.decisions)) return null;
|
|
209
272
|
if (!Number.isSafeInteger(body.configVersion) || typeof body.persist !== "boolean") return null;
|
|
@@ -216,7 +279,10 @@ function parseResolveResponse(body) {
|
|
|
216
279
|
if (decision) decisions[id] = decision;
|
|
217
280
|
}
|
|
218
281
|
const m = persist && typeof body.m === "string" && body.m.length <= 1024 ? body.m : void 0;
|
|
219
|
-
if (body.preview === true)
|
|
282
|
+
if (body.preview === true) {
|
|
283
|
+
const explain = isRecord(body.explain) ? body.explain : void 0;
|
|
284
|
+
return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist: false, preview: true, decisions, ...explain ? { explain } : {} };
|
|
285
|
+
}
|
|
220
286
|
return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist, vid, ...pin ? { pin } : {}, decisions, ...m ? { m } : {} };
|
|
221
287
|
}
|
|
222
288
|
function parseDecision(raw) {
|
|
@@ -225,10 +291,12 @@ function parseDecision(raw) {
|
|
|
225
291
|
if (!REASONS.includes(raw.reason)) return null;
|
|
226
292
|
const content = {};
|
|
227
293
|
for (const [field, value] of Object.entries(raw.content)) if (typeof value === "string") content[field] = value;
|
|
294
|
+
const rung = RUNGS.includes(raw.rung) ? raw.rung : void 0;
|
|
228
295
|
return {
|
|
229
296
|
variantId: raw.variantId,
|
|
230
297
|
counterfactualVariantId: raw.counterfactualVariantId,
|
|
231
298
|
reason: raw.reason,
|
|
299
|
+
...rung && { rung },
|
|
232
300
|
content
|
|
233
301
|
};
|
|
234
302
|
}
|
|
@@ -274,6 +342,7 @@ function createClient(options) {
|
|
|
274
342
|
if (response.preview) {
|
|
275
343
|
previewing = true;
|
|
276
344
|
stopTracking(true);
|
|
345
|
+
if (response.explain) win.console?.info("[kharon] debug link: why this page shows what it does", response.explain);
|
|
277
346
|
return;
|
|
278
347
|
}
|
|
279
348
|
if (response.persist && response.vid && consent !== "denied") {
|
|
@@ -479,6 +548,8 @@ function applyDecision(el, decision, baseURI) {
|
|
|
479
548
|
el.setAttribute(SLOT_ATTRIBUTES.variant, decision.variantId ?? "");
|
|
480
549
|
el.setAttribute(SLOT_ATTRIBUTES.counterfactual, decision.counterfactualVariantId ?? "");
|
|
481
550
|
el.setAttribute(SLOT_ATTRIBUTES.reason, decision.reason);
|
|
551
|
+
if (decision.rung) el.setAttribute(SLOT_ATTRIBUTES.rung, decision.rung);
|
|
552
|
+
else el.removeAttribute(SLOT_ATTRIBUTES.rung);
|
|
482
553
|
}
|
|
483
554
|
function reveal(root) {
|
|
484
555
|
root.getElementById(ANTI_FLICKER_ID)?.remove();
|
|
@@ -574,6 +645,9 @@ function setConsent(consent) {
|
|
|
574
645
|
function conversion(goal, value) {
|
|
575
646
|
current?.conversion(goal, value);
|
|
576
647
|
}
|
|
648
|
+
function signup() {
|
|
649
|
+
current?.conversion(SIGNUP_GOAL);
|
|
650
|
+
}
|
|
577
651
|
function refresh() {
|
|
578
652
|
return current?.refresh() ?? Promise.resolve();
|
|
579
653
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,52 @@
|
|
|
1
|
-
type
|
|
1
|
+
type SignalSource = "campaign_link" | "form_email" | "known_visitor" | "reverse_ip" | "referrer";
|
|
2
|
+
type AttributeKey = "company.domain" | "company.name" | "company.legalName" | "company.industry"
|
|
3
|
+
/** NAICS sector id ("finance", "health-care"), rolled up from `company.naics` by `naicsSector`. */
|
|
4
|
+
| "company.sector" | "company.naics" | "company.sic" | "company.employeeCount" | "company.employeeBand" | "company.revenueBand" | "company.country" | "company.region" | "company.city" | "company.founded" | "company.ownership" | "company.ticker" | "crm.lists" | "crm.stage" | "crm.owner" | "crm.isCustomer" | "visitor.role" | "visitor.networkType" | "visitor.identified" | "visitor.country" | "intent.campaign" | "intent.utmSource" | "intent.utmMedium" | "intent.utmTerm" | "intent.referrerHost";
|
|
5
|
+
type AttributeValue = string | number | boolean;
|
|
6
|
+
type ListAttributeKey = "crm.lists";
|
|
7
|
+
type ScalarAttributeKey = Exclude<AttributeKey, ListAttributeKey>;
|
|
8
|
+
type IdentityContext = Partial<Record<ScalarAttributeKey, AttributeValue>> & Partial<Record<ListAttributeKey, string[]>>;
|
|
9
|
+
type Condition = ({
|
|
10
|
+
attr: ScalarAttributeKey;
|
|
11
|
+
op: "eq";
|
|
12
|
+
value: AttributeValue;
|
|
13
|
+
} | {
|
|
14
|
+
attr: ScalarAttributeKey;
|
|
15
|
+
op: "in";
|
|
16
|
+
value: AttributeValue[];
|
|
17
|
+
}
|
|
18
|
+
/** `startsWith` is a prefix match, for hierarchical codes: NAICS "51" covers "5112". */
|
|
19
|
+
| {
|
|
20
|
+
attr: ScalarAttributeKey;
|
|
21
|
+
op: "contains" | "startsWith";
|
|
22
|
+
value: string;
|
|
23
|
+
} | {
|
|
24
|
+
attr: ScalarAttributeKey;
|
|
25
|
+
op: "gte" | "lte";
|
|
26
|
+
value: number;
|
|
27
|
+
}
|
|
28
|
+
/** The list attribute holds this id, e.g. the account is on CRM list "hs-123". */
|
|
29
|
+
| {
|
|
30
|
+
attr: ListAttributeKey;
|
|
31
|
+
op: "has";
|
|
32
|
+
value: string;
|
|
33
|
+
} | {
|
|
34
|
+
attr: AttributeKey;
|
|
35
|
+
op: "exists";
|
|
36
|
+
}) & {
|
|
37
|
+
/**
|
|
38
|
+
* For near matching: a visitor who fails this condition never gets a near match. Exact matching
|
|
39
|
+
* ignores it. Conditions under `not` are always required.
|
|
40
|
+
*/
|
|
41
|
+
required?: boolean;
|
|
42
|
+
};
|
|
2
43
|
type SlotContent = Record<string, string>;
|
|
44
|
+
type VariantStatus = "draft" | "live" | "paused" | "retired";
|
|
3
45
|
type DecisionReason = "matched" | "default" | "holdout";
|
|
46
|
+
type DecisionRung = "exact" | "near" | "segment" | "default";
|
|
4
47
|
interface Decision {
|
|
5
48
|
slotId: string;
|
|
6
|
-
/** The variant shown, or null when default content was shown. */
|
|
49
|
+
/** The variant or segment row (`seg:…`) shown, or null when default content was shown. */
|
|
7
50
|
variantId: string | null;
|
|
8
51
|
/**
|
|
9
52
|
* For holdout visitors: the variant they *would* have seen. This is the control group for that
|
|
@@ -11,9 +54,65 @@ interface Decision {
|
|
|
11
54
|
*/
|
|
12
55
|
counterfactualVariantId: string | null;
|
|
13
56
|
reason: DecisionReason;
|
|
57
|
+
rung: DecisionRung;
|
|
14
58
|
/** Content with all tokens rendered. */
|
|
15
59
|
content: SlotContent;
|
|
16
60
|
}
|
|
61
|
+
type ConsentChoice = "granted" | "denied" | "unknown";
|
|
62
|
+
type CandidateOutcome = "wins" | "outranked" | "skipped" | "held-out";
|
|
63
|
+
type ExplainOutcome = Exclude<CandidateOutcome, "held-out"> | "no-match" | "not-live";
|
|
64
|
+
interface ConditionExplanation {
|
|
65
|
+
attr: AttributeKey;
|
|
66
|
+
op: Condition["op"];
|
|
67
|
+
/** The rule's value; absent for `exists`. */
|
|
68
|
+
value?: Extract<Condition, {
|
|
69
|
+
value: unknown;
|
|
70
|
+
}>["value"];
|
|
71
|
+
/** The visitor's value for `attr`, or null when they have none. */
|
|
72
|
+
actual: AttributeValue | string[] | null;
|
|
73
|
+
/** Whether this condition alone holds. Null when the attribute can't be known here (see `unavailable`). */
|
|
74
|
+
matched: boolean | null;
|
|
75
|
+
}
|
|
76
|
+
interface AudienceExplanation {
|
|
77
|
+
id: string;
|
|
78
|
+
name: string;
|
|
79
|
+
matched: boolean;
|
|
80
|
+
/** Some condition reads an attribute this explanation can't see, so `matched` may be wrong for real visitors. */
|
|
81
|
+
unknown: boolean;
|
|
82
|
+
/** Every leaf condition of the rule, in order. `not`, `any` and `all` are folded into `matched`. */
|
|
83
|
+
conditions: ConditionExplanation[];
|
|
84
|
+
}
|
|
85
|
+
interface VariantExplanation {
|
|
86
|
+
variantId: string;
|
|
87
|
+
audienceId: string;
|
|
88
|
+
status: VariantStatus;
|
|
89
|
+
/** Whether the variant's audience matches this visitor, whatever its status. */
|
|
90
|
+
audienceMatched: boolean;
|
|
91
|
+
outcome: ExplainOutcome;
|
|
92
|
+
/** For `skipped`: the attributes with no value and no fallback that stopped it rendering. */
|
|
93
|
+
missing?: string[];
|
|
94
|
+
}
|
|
95
|
+
interface SlotExplanation {
|
|
96
|
+
/** The variant or segment row (`seg:…`) shown, or null for default content. */
|
|
97
|
+
variantId: string | null;
|
|
98
|
+
reason: DecisionReason;
|
|
99
|
+
/** The step of the fallback ladder that chose the content. */
|
|
100
|
+
rung: DecisionRung;
|
|
101
|
+
/** The text each field shows. */
|
|
102
|
+
content: Record<string, string>;
|
|
103
|
+
variants: VariantExplanation[];
|
|
104
|
+
}
|
|
105
|
+
interface SiteExplanation {
|
|
106
|
+
company: {
|
|
107
|
+
domain: string;
|
|
108
|
+
source: SignalSource;
|
|
109
|
+
confidence: number;
|
|
110
|
+
} | null;
|
|
111
|
+
/** The flat attributes audiences and templates see. */
|
|
112
|
+
context: IdentityContext;
|
|
113
|
+
audiences: AudienceExplanation[];
|
|
114
|
+
slots: Record<string, SlotExplanation>;
|
|
115
|
+
}
|
|
17
116
|
declare const RESOLVE_API_VERSION = 1;
|
|
18
117
|
interface DeclaredSlot {
|
|
19
118
|
id: string;
|
|
@@ -40,8 +139,11 @@ export interface ResolveRequest {
|
|
|
40
139
|
/** The session's config pin from an earlier response (`"<version>.<epoch>"`), to keep its copy stable. */
|
|
41
140
|
pin?: string;
|
|
42
141
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
142
|
+
/**
|
|
143
|
+
* What one slot shows. The same fields `<Slot>` writes as `data-ge-*` attributes. `rung` is absent in
|
|
144
|
+
* answers from servers that predate the fallback ladder.
|
|
145
|
+
*/
|
|
146
|
+
export type ResolvedSlot = Pick<Decision, "variantId" | "counterfactualVariantId" | "reason" | "content"> & Partial<Pick<Decision, "rung">>;
|
|
45
147
|
export interface ResolveResponse {
|
|
46
148
|
v: typeof RESOLVE_API_VERSION;
|
|
47
149
|
/** Published config version the decisions came from; 0 when the site has never published. */
|
|
@@ -64,6 +166,11 @@ export interface ResolveResponse {
|
|
|
64
166
|
* for a synthetic visitor. The package stores nothing, forgets nothing and doesn't measure.
|
|
65
167
|
*/
|
|
66
168
|
preview?: true;
|
|
169
|
+
/**
|
|
170
|
+
* A debug link (docs/design/resolve-debug): why each requested slot shows what it does to this
|
|
171
|
+
* request, resolved from what our CDN saw. Only with `preview`; the browser SDK logs it to the console.
|
|
172
|
+
*/
|
|
173
|
+
explain?: SiteExplanation;
|
|
67
174
|
}
|
|
68
175
|
/**
|
|
69
176
|
* A short, stable fingerprint of a slot's defaults, so resolve can tell when the code and the
|
|
@@ -154,6 +261,11 @@ export declare function init(options: KharonOptions): Kharon;
|
|
|
154
261
|
/** Same as `instance.setConsent`. Safe to call before `init`; the choice is used by the first resolve. */
|
|
155
262
|
export declare function setConsent(consent: Consent): void;
|
|
156
263
|
export declare function conversion(goal: string, value?: number): void;
|
|
264
|
+
/**
|
|
265
|
+
* Records a signup from the page. Prefer `recordSignup` in `@kharon-labs/server` from your signup
|
|
266
|
+
* handler: it counts only signups that succeeded, and ones whose browser blocks scripts.
|
|
267
|
+
*/
|
|
268
|
+
export declare function signup(): void;
|
|
157
269
|
export declare function refresh(): Promise<void>;
|
|
158
270
|
|
|
159
271
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
// src/engine/analytics/types.ts
|
|
2
|
+
var SIGNUP_GOAL = "signup";
|
|
3
|
+
|
|
4
|
+
// src/engine/analytics/touch.ts
|
|
5
|
+
var MAX_TOUCH_VALUE = 100;
|
|
6
|
+
var CLICK_ID_PARAMS = {
|
|
7
|
+
gclid: "google",
|
|
8
|
+
gbraid: "google",
|
|
9
|
+
wbraid: "google",
|
|
10
|
+
msclkid: "microsoft",
|
|
11
|
+
fbclid: "meta",
|
|
12
|
+
li_fat_id: "linkedin",
|
|
13
|
+
ttclid: "tiktok",
|
|
14
|
+
twclid: "x"
|
|
15
|
+
};
|
|
16
|
+
var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
|
|
17
|
+
var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
|
|
18
|
+
var KHARON_LINK_PARAM = "ge_co";
|
|
19
|
+
function touchFromUrl(url, referrer) {
|
|
20
|
+
const touch = {};
|
|
21
|
+
let page;
|
|
22
|
+
try {
|
|
23
|
+
page = new URL(url);
|
|
24
|
+
} catch {
|
|
25
|
+
return touch;
|
|
26
|
+
}
|
|
27
|
+
for (const field of UTM_FIELDS) {
|
|
28
|
+
const value = clean(page.searchParams.get(`utm_${field}`));
|
|
29
|
+
if (value) touch[field] = value;
|
|
30
|
+
}
|
|
31
|
+
for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
|
|
32
|
+
if (page.searchParams.get(param)) {
|
|
33
|
+
touch.clickId = kind;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
|
|
38
|
+
const from = referrerHost(referrer);
|
|
39
|
+
if (from && from !== bareHost(page.hostname)) touch.referrer = from;
|
|
40
|
+
return touch;
|
|
41
|
+
}
|
|
42
|
+
function clean(value) {
|
|
43
|
+
const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
44
|
+
return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
|
|
45
|
+
}
|
|
46
|
+
function referrerHost(referrer) {
|
|
47
|
+
if (!referrer) return null;
|
|
48
|
+
try {
|
|
49
|
+
const { protocol, hostname } = new URL(referrer);
|
|
50
|
+
return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function bareHost(hostname) {
|
|
56
|
+
return hostname.toLowerCase().replace(/^www\./, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
1
59
|
// src/engine/analytics/beacon.ts
|
|
2
60
|
var BEACON_VERSION = 1;
|
|
3
61
|
var MAX_BEACON_EVENTS = 50;
|
|
@@ -6,7 +64,9 @@ var SLOT_ATTRIBUTES = {
|
|
|
6
64
|
variant: "data-ge-variant",
|
|
7
65
|
counterfactual: "data-ge-counterfactual",
|
|
8
66
|
/** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
|
|
9
|
-
reason: "data-ge-reason"
|
|
67
|
+
reason: "data-ge-reason",
|
|
68
|
+
/** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
|
|
69
|
+
rung: "data-ge-rung"
|
|
10
70
|
};
|
|
11
71
|
|
|
12
72
|
// packages/browser/src/tracker.ts
|
|
@@ -25,7 +85,7 @@ function startTracker({
|
|
|
25
85
|
crossOrigin = false
|
|
26
86
|
}) {
|
|
27
87
|
const win = root.defaultView ?? window;
|
|
28
|
-
const sessionId =
|
|
88
|
+
const { id: sessionId, started } = sessionFor(win);
|
|
29
89
|
const queue = [];
|
|
30
90
|
const seenSlots = /* @__PURE__ */ new Set();
|
|
31
91
|
const push = (type, body) => {
|
|
@@ -35,7 +95,7 @@ function startTracker({
|
|
|
35
95
|
const flush = () => {
|
|
36
96
|
while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
|
|
37
97
|
};
|
|
38
|
-
push("page_view", { pageId, configVersion });
|
|
98
|
+
push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
|
|
39
99
|
const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
|
|
40
100
|
const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
|
|
41
101
|
(entries) => {
|
|
@@ -101,22 +161,23 @@ function readSlot(el) {
|
|
|
101
161
|
return {
|
|
102
162
|
slotId,
|
|
103
163
|
variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
|
|
104
|
-
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
|
|
164
|
+
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
|
|
165
|
+
...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
|
|
105
166
|
};
|
|
106
167
|
}
|
|
107
168
|
function closestSlot(target) {
|
|
108
169
|
const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
|
|
109
170
|
return el ? readSlot(el) : null;
|
|
110
171
|
}
|
|
111
|
-
function
|
|
172
|
+
function sessionFor(win) {
|
|
112
173
|
try {
|
|
113
174
|
const existing = win.sessionStorage.getItem(SESSION_KEY);
|
|
114
|
-
if (existing) return existing;
|
|
175
|
+
if (existing) return { id: existing, started: false };
|
|
115
176
|
const id = uuid(win);
|
|
116
177
|
win.sessionStorage.setItem(SESSION_KEY, id);
|
|
117
|
-
return id;
|
|
178
|
+
return { id, started: true };
|
|
118
179
|
} catch {
|
|
119
|
-
return uuid(win);
|
|
180
|
+
return { id: uuid(win), started: true };
|
|
120
181
|
}
|
|
121
182
|
}
|
|
122
183
|
function uuid(win) {
|
|
@@ -162,6 +223,7 @@ function hashSlotDefaults(content) {
|
|
|
162
223
|
var RESOLVE_PATH = "/api/v1/resolve";
|
|
163
224
|
var COLLECT_V2_PATH = "/api/v1/collect";
|
|
164
225
|
var REASONS = ["matched", "default", "holdout"];
|
|
226
|
+
var RUNGS = ["exact", "near", "segment", "default"];
|
|
165
227
|
function parseResolveResponse(body) {
|
|
166
228
|
if (!isRecord(body) || body.v !== RESOLVE_API_VERSION || !isRecord(body.decisions)) return null;
|
|
167
229
|
if (!Number.isSafeInteger(body.configVersion) || typeof body.persist !== "boolean") return null;
|
|
@@ -174,7 +236,10 @@ function parseResolveResponse(body) {
|
|
|
174
236
|
if (decision) decisions[id] = decision;
|
|
175
237
|
}
|
|
176
238
|
const m = persist && typeof body.m === "string" && body.m.length <= 1024 ? body.m : void 0;
|
|
177
|
-
if (body.preview === true)
|
|
239
|
+
if (body.preview === true) {
|
|
240
|
+
const explain = isRecord(body.explain) ? body.explain : void 0;
|
|
241
|
+
return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist: false, preview: true, decisions, ...explain ? { explain } : {} };
|
|
242
|
+
}
|
|
178
243
|
return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist, vid, ...pin ? { pin } : {}, decisions, ...m ? { m } : {} };
|
|
179
244
|
}
|
|
180
245
|
function parseDecision(raw) {
|
|
@@ -183,10 +248,12 @@ function parseDecision(raw) {
|
|
|
183
248
|
if (!REASONS.includes(raw.reason)) return null;
|
|
184
249
|
const content = {};
|
|
185
250
|
for (const [field, value] of Object.entries(raw.content)) if (typeof value === "string") content[field] = value;
|
|
251
|
+
const rung = RUNGS.includes(raw.rung) ? raw.rung : void 0;
|
|
186
252
|
return {
|
|
187
253
|
variantId: raw.variantId,
|
|
188
254
|
counterfactualVariantId: raw.counterfactualVariantId,
|
|
189
255
|
reason: raw.reason,
|
|
256
|
+
...rung && { rung },
|
|
190
257
|
content
|
|
191
258
|
};
|
|
192
259
|
}
|
|
@@ -232,6 +299,7 @@ function createClient(options) {
|
|
|
232
299
|
if (response.preview) {
|
|
233
300
|
previewing = true;
|
|
234
301
|
stopTracking(true);
|
|
302
|
+
if (response.explain) win.console?.info("[kharon] debug link: why this page shows what it does", response.explain);
|
|
235
303
|
return;
|
|
236
304
|
}
|
|
237
305
|
if (response.persist && response.vid && consent !== "denied") {
|
|
@@ -437,6 +505,8 @@ function applyDecision(el, decision, baseURI) {
|
|
|
437
505
|
el.setAttribute(SLOT_ATTRIBUTES.variant, decision.variantId ?? "");
|
|
438
506
|
el.setAttribute(SLOT_ATTRIBUTES.counterfactual, decision.counterfactualVariantId ?? "");
|
|
439
507
|
el.setAttribute(SLOT_ATTRIBUTES.reason, decision.reason);
|
|
508
|
+
if (decision.rung) el.setAttribute(SLOT_ATTRIBUTES.rung, decision.rung);
|
|
509
|
+
else el.removeAttribute(SLOT_ATTRIBUTES.rung);
|
|
440
510
|
}
|
|
441
511
|
function reveal(root) {
|
|
442
512
|
root.getElementById(ANTI_FLICKER_ID)?.remove();
|
|
@@ -532,6 +602,9 @@ function setConsent(consent) {
|
|
|
532
602
|
function conversion(goal, value) {
|
|
533
603
|
current?.conversion(goal, value);
|
|
534
604
|
}
|
|
605
|
+
function signup() {
|
|
606
|
+
current?.conversion(SIGNUP_GOAL);
|
|
607
|
+
}
|
|
535
608
|
function refresh() {
|
|
536
609
|
return current?.refresh() ?? Promise.resolve();
|
|
537
610
|
}
|
|
@@ -552,5 +625,6 @@ export {
|
|
|
552
625
|
refresh,
|
|
553
626
|
reveal,
|
|
554
627
|
scanSlots,
|
|
555
|
-
setConsent
|
|
628
|
+
setConsent,
|
|
629
|
+
signup
|
|
556
630
|
};
|
package/dist/tracker.cjs
CHANGED
|
@@ -27,6 +27,61 @@ __export(tracker_exports, {
|
|
|
27
27
|
});
|
|
28
28
|
module.exports = __toCommonJS(tracker_exports);
|
|
29
29
|
|
|
30
|
+
// src/engine/analytics/touch.ts
|
|
31
|
+
var MAX_TOUCH_VALUE = 100;
|
|
32
|
+
var CLICK_ID_PARAMS = {
|
|
33
|
+
gclid: "google",
|
|
34
|
+
gbraid: "google",
|
|
35
|
+
wbraid: "google",
|
|
36
|
+
msclkid: "microsoft",
|
|
37
|
+
fbclid: "meta",
|
|
38
|
+
li_fat_id: "linkedin",
|
|
39
|
+
ttclid: "tiktok",
|
|
40
|
+
twclid: "x"
|
|
41
|
+
};
|
|
42
|
+
var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
|
|
43
|
+
var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
|
|
44
|
+
var KHARON_LINK_PARAM = "ge_co";
|
|
45
|
+
function touchFromUrl(url, referrer) {
|
|
46
|
+
const touch = {};
|
|
47
|
+
let page;
|
|
48
|
+
try {
|
|
49
|
+
page = new URL(url);
|
|
50
|
+
} catch {
|
|
51
|
+
return touch;
|
|
52
|
+
}
|
|
53
|
+
for (const field of UTM_FIELDS) {
|
|
54
|
+
const value = clean(page.searchParams.get(`utm_${field}`));
|
|
55
|
+
if (value) touch[field] = value;
|
|
56
|
+
}
|
|
57
|
+
for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
|
|
58
|
+
if (page.searchParams.get(param)) {
|
|
59
|
+
touch.clickId = kind;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
|
|
64
|
+
const from = referrerHost(referrer);
|
|
65
|
+
if (from && from !== bareHost(page.hostname)) touch.referrer = from;
|
|
66
|
+
return touch;
|
|
67
|
+
}
|
|
68
|
+
function clean(value) {
|
|
69
|
+
const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
70
|
+
return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
|
|
71
|
+
}
|
|
72
|
+
function referrerHost(referrer) {
|
|
73
|
+
if (!referrer) return null;
|
|
74
|
+
try {
|
|
75
|
+
const { protocol, hostname } = new URL(referrer);
|
|
76
|
+
return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function bareHost(hostname) {
|
|
82
|
+
return hostname.toLowerCase().replace(/^www\./, "");
|
|
83
|
+
}
|
|
84
|
+
|
|
30
85
|
// src/engine/analytics/beacon.ts
|
|
31
86
|
var BEACON_VERSION = 1;
|
|
32
87
|
var MAX_BEACON_EVENTS = 50;
|
|
@@ -35,7 +90,9 @@ var SLOT_ATTRIBUTES = {
|
|
|
35
90
|
variant: "data-ge-variant",
|
|
36
91
|
counterfactual: "data-ge-counterfactual",
|
|
37
92
|
/** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
|
|
38
|
-
reason: "data-ge-reason"
|
|
93
|
+
reason: "data-ge-reason",
|
|
94
|
+
/** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
|
|
95
|
+
rung: "data-ge-rung"
|
|
39
96
|
};
|
|
40
97
|
|
|
41
98
|
// packages/browser/src/tracker.ts
|
|
@@ -54,7 +111,7 @@ function startTracker({
|
|
|
54
111
|
crossOrigin = false
|
|
55
112
|
}) {
|
|
56
113
|
const win = root.defaultView ?? window;
|
|
57
|
-
const sessionId =
|
|
114
|
+
const { id: sessionId, started } = sessionFor(win);
|
|
58
115
|
const queue = [];
|
|
59
116
|
const seenSlots = /* @__PURE__ */ new Set();
|
|
60
117
|
const push = (type, body) => {
|
|
@@ -64,7 +121,7 @@ function startTracker({
|
|
|
64
121
|
const flush = () => {
|
|
65
122
|
while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
|
|
66
123
|
};
|
|
67
|
-
push("page_view", { pageId, configVersion });
|
|
124
|
+
push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
|
|
68
125
|
const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
|
|
69
126
|
const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
|
|
70
127
|
(entries) => {
|
|
@@ -130,22 +187,23 @@ function readSlot(el) {
|
|
|
130
187
|
return {
|
|
131
188
|
slotId,
|
|
132
189
|
variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
|
|
133
|
-
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
|
|
190
|
+
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
|
|
191
|
+
...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
|
|
134
192
|
};
|
|
135
193
|
}
|
|
136
194
|
function closestSlot(target) {
|
|
137
195
|
const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
|
|
138
196
|
return el ? readSlot(el) : null;
|
|
139
197
|
}
|
|
140
|
-
function
|
|
198
|
+
function sessionFor(win) {
|
|
141
199
|
try {
|
|
142
200
|
const existing = win.sessionStorage.getItem(SESSION_KEY);
|
|
143
|
-
if (existing) return existing;
|
|
201
|
+
if (existing) return { id: existing, started: false };
|
|
144
202
|
const id = uuid(win);
|
|
145
203
|
win.sessionStorage.setItem(SESSION_KEY, id);
|
|
146
|
-
return id;
|
|
204
|
+
return { id, started: true };
|
|
147
205
|
} catch {
|
|
148
|
-
return uuid(win);
|
|
206
|
+
return { id: uuid(win), started: true };
|
|
149
207
|
}
|
|
150
208
|
}
|
|
151
209
|
function uuid(win) {
|
package/dist/tracker.d.ts
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
type SignalSource = "campaign_link" | "form_email" | "known_visitor" | "reverse_ip" | "referrer";
|
|
2
|
+
type DecisionRung = "exact" | "near" | "segment" | "default";
|
|
3
|
+
interface Touch$1 {
|
|
4
|
+
/** `utm_source`, `utm_medium`, … as tagged, trimmed to `MAX_TOUCH_VALUE` characters. */
|
|
5
|
+
source?: string;
|
|
6
|
+
medium?: string;
|
|
7
|
+
campaign?: string;
|
|
8
|
+
term?: string;
|
|
9
|
+
content?: string;
|
|
10
|
+
/** Which ad network's click id the URL carried (`gclid` → `google`). The id itself is dropped. */
|
|
11
|
+
clickId?: ClickIdKind;
|
|
12
|
+
/** The landing URL was one of our campaign links (`?ge_co=`). */
|
|
13
|
+
kharonLink?: true;
|
|
14
|
+
/** Host of an external referring page, lowercased and without `www.`. */
|
|
15
|
+
referrer?: string;
|
|
16
|
+
}
|
|
17
|
+
declare const CLICK_ID_PARAMS: {
|
|
18
|
+
readonly gclid: "google";
|
|
19
|
+
readonly gbraid: "google";
|
|
20
|
+
readonly wbraid: "google";
|
|
21
|
+
readonly msclkid: "microsoft";
|
|
22
|
+
readonly fbclid: "meta";
|
|
23
|
+
readonly li_fat_id: "linkedin";
|
|
24
|
+
readonly ttclid: "tiktok";
|
|
25
|
+
readonly twclid: "x";
|
|
26
|
+
};
|
|
27
|
+
type ClickIdKind = (typeof CLICK_ID_PARAMS)[keyof typeof CLICK_ID_PARAMS];
|
|
2
28
|
interface BaseEvent {
|
|
3
29
|
siteId: string;
|
|
4
30
|
visitorId: string;
|
|
@@ -20,6 +46,11 @@ interface PageViewEvent extends BaseEvent {
|
|
|
20
46
|
pageId: string;
|
|
21
47
|
/** Site config version that was resolved, so results are attributable to what was live. */
|
|
22
48
|
configVersion: number;
|
|
49
|
+
/**
|
|
50
|
+
* How the session arrived, on its first page view only (`{}` for a direct visit). Absent on later
|
|
51
|
+
* page views and from trackers older than signup attribution.
|
|
52
|
+
*/
|
|
53
|
+
touch?: Touch$1;
|
|
23
54
|
}
|
|
24
55
|
interface SlotImpressionEvent extends BaseEvent {
|
|
25
56
|
type: "slot_impression";
|
|
@@ -27,6 +58,8 @@ interface SlotImpressionEvent extends BaseEvent {
|
|
|
27
58
|
variantId: string | null;
|
|
28
59
|
/** Set for holdout visitors: the variant withheld from them. See Decision.counterfactualVariantId. */
|
|
29
60
|
counterfactualVariantId: string | null;
|
|
61
|
+
/** The fallback-ladder step behind the decision (Decision.rung). Absent from older scripts and events. */
|
|
62
|
+
rung?: DecisionRung;
|
|
30
63
|
}
|
|
31
64
|
interface InteractionEvent extends BaseEvent {
|
|
32
65
|
type: "interaction";
|
|
@@ -38,9 +71,11 @@ interface ConversionEvent extends BaseEvent {
|
|
|
38
71
|
type: "conversion";
|
|
39
72
|
goal: string;
|
|
40
73
|
value?: number;
|
|
74
|
+
/** `server`: confirmed by the site's backend (`POST /api/v1/signups`), not reported by the page. */
|
|
75
|
+
source?: "server";
|
|
41
76
|
}
|
|
42
77
|
type AnalyticsEvent = PageViewEvent | SlotImpressionEvent | InteractionEvent | ConversionEvent;
|
|
43
|
-
type ServerStamped = "siteId" | "visitorId" | "ts" | "companyDomain" | "companySource";
|
|
78
|
+
type ServerStamped = "siteId" | "visitorId" | "ts" | "companyDomain" | "companySource" | "source";
|
|
44
79
|
type BeaconEvent = {
|
|
45
80
|
[K in AnalyticsEvent["type"]]: Omit<Extract<AnalyticsEvent, {
|
|
46
81
|
type: K;
|
package/dist/tracker.js
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
+
// src/engine/analytics/touch.ts
|
|
2
|
+
var MAX_TOUCH_VALUE = 100;
|
|
3
|
+
var CLICK_ID_PARAMS = {
|
|
4
|
+
gclid: "google",
|
|
5
|
+
gbraid: "google",
|
|
6
|
+
wbraid: "google",
|
|
7
|
+
msclkid: "microsoft",
|
|
8
|
+
fbclid: "meta",
|
|
9
|
+
li_fat_id: "linkedin",
|
|
10
|
+
ttclid: "tiktok",
|
|
11
|
+
twclid: "x"
|
|
12
|
+
};
|
|
13
|
+
var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
|
|
14
|
+
var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
|
|
15
|
+
var KHARON_LINK_PARAM = "ge_co";
|
|
16
|
+
function touchFromUrl(url, referrer) {
|
|
17
|
+
const touch = {};
|
|
18
|
+
let page;
|
|
19
|
+
try {
|
|
20
|
+
page = new URL(url);
|
|
21
|
+
} catch {
|
|
22
|
+
return touch;
|
|
23
|
+
}
|
|
24
|
+
for (const field of UTM_FIELDS) {
|
|
25
|
+
const value = clean(page.searchParams.get(`utm_${field}`));
|
|
26
|
+
if (value) touch[field] = value;
|
|
27
|
+
}
|
|
28
|
+
for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
|
|
29
|
+
if (page.searchParams.get(param)) {
|
|
30
|
+
touch.clickId = kind;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
|
|
35
|
+
const from = referrerHost(referrer);
|
|
36
|
+
if (from && from !== bareHost(page.hostname)) touch.referrer = from;
|
|
37
|
+
return touch;
|
|
38
|
+
}
|
|
39
|
+
function clean(value) {
|
|
40
|
+
const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
41
|
+
return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
|
|
42
|
+
}
|
|
43
|
+
function referrerHost(referrer) {
|
|
44
|
+
if (!referrer) return null;
|
|
45
|
+
try {
|
|
46
|
+
const { protocol, hostname } = new URL(referrer);
|
|
47
|
+
return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function bareHost(hostname) {
|
|
53
|
+
return hostname.toLowerCase().replace(/^www\./, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
1
56
|
// src/engine/analytics/beacon.ts
|
|
2
57
|
var BEACON_VERSION = 1;
|
|
3
58
|
var MAX_BEACON_EVENTS = 50;
|
|
@@ -6,7 +61,9 @@ var SLOT_ATTRIBUTES = {
|
|
|
6
61
|
variant: "data-ge-variant",
|
|
7
62
|
counterfactual: "data-ge-counterfactual",
|
|
8
63
|
/** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
|
|
9
|
-
reason: "data-ge-reason"
|
|
64
|
+
reason: "data-ge-reason",
|
|
65
|
+
/** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
|
|
66
|
+
rung: "data-ge-rung"
|
|
10
67
|
};
|
|
11
68
|
|
|
12
69
|
// packages/browser/src/tracker.ts
|
|
@@ -25,7 +82,7 @@ function startTracker({
|
|
|
25
82
|
crossOrigin = false
|
|
26
83
|
}) {
|
|
27
84
|
const win = root.defaultView ?? window;
|
|
28
|
-
const sessionId =
|
|
85
|
+
const { id: sessionId, started } = sessionFor(win);
|
|
29
86
|
const queue = [];
|
|
30
87
|
const seenSlots = /* @__PURE__ */ new Set();
|
|
31
88
|
const push = (type, body) => {
|
|
@@ -35,7 +92,7 @@ function startTracker({
|
|
|
35
92
|
const flush = () => {
|
|
36
93
|
while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
|
|
37
94
|
};
|
|
38
|
-
push("page_view", { pageId, configVersion });
|
|
95
|
+
push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
|
|
39
96
|
const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
|
|
40
97
|
const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
|
|
41
98
|
(entries) => {
|
|
@@ -101,22 +158,23 @@ function readSlot(el) {
|
|
|
101
158
|
return {
|
|
102
159
|
slotId,
|
|
103
160
|
variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
|
|
104
|
-
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
|
|
161
|
+
counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
|
|
162
|
+
...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
|
|
105
163
|
};
|
|
106
164
|
}
|
|
107
165
|
function closestSlot(target) {
|
|
108
166
|
const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
|
|
109
167
|
return el ? readSlot(el) : null;
|
|
110
168
|
}
|
|
111
|
-
function
|
|
169
|
+
function sessionFor(win) {
|
|
112
170
|
try {
|
|
113
171
|
const existing = win.sessionStorage.getItem(SESSION_KEY);
|
|
114
|
-
if (existing) return existing;
|
|
172
|
+
if (existing) return { id: existing, started: false };
|
|
115
173
|
const id = uuid(win);
|
|
116
174
|
win.sessionStorage.setItem(SESSION_KEY, id);
|
|
117
|
-
return id;
|
|
175
|
+
return { id, started: true };
|
|
118
176
|
} catch {
|
|
119
|
-
return uuid(win);
|
|
177
|
+
return { id: uuid(win), started: true };
|
|
120
178
|
}
|
|
121
179
|
}
|
|
122
180
|
function uuid(win) {
|
package/dist/v1.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";(()=>{var Te=Object.defineProperty;var Ve=(e,t)=>{for(var n in t)Te(e,n,{get:t[n],enumerable:!0})};var ee={};Ve(ee,{ANTI_FLICKER_SNIPPET:()=>me,DEFAULT_API_BASE:()=>X,DEFAULT_TIMEOUT_MS:()=>U,VISITOR_COOKIE:()=>R,antiFlickerStart:()=>q,applyDecision:()=>J,conversion:()=>We,createClient:()=>F,hashSlotDefaults:()=>O,init:()=>Q,isWritable:()=>Z,pageIdFor:()=>Y,parseResolveResponse:()=>$,refresh:()=>qe,reveal:()=>B,scanSlots:()=>W,setConsent:()=>He});var d={slot:"data-ge-slot",variant:"data-ge-variant",counterfactual:"data-ge-counterfactual",reason:"data-ge-reason"};var Oe="/api/collect",D="ge_sid",w="ge_pin",ne=.5,Ce=2e3;function re({pageId:e,configVersion:t,endpoint:n=Oe,root:o=document,match:r,envelope:s=c=>({v:1,events:c,...r?{m:r}:{}}),crossOrigin:a=!1}){let c=o.defaultView??window,u=be(c),f=[],h=new Set,A=(l,p)=>{f.push({type:l,sessionId:u,...p}),f.length>=50&&m()},m=()=>{for(;f.length;)Pe(c,n,s(f.splice(0,50)),a)};A("page_view",{pageId:e,configVersion:t});let i=Array.from(o.querySelectorAll(`[${d.slot}]`)),v="IntersectionObserver"in c?new c.IntersectionObserver(l=>{for(let p of l){if(!p.isIntersecting||p.intersectionRatio<ne)continue;let g=se(p.target);v?.unobserve(p.target),!(!g||h.has(g.slotId))&&(h.add(g.slotId),A("slot_impression",g))}},{threshold:ne}):null;for(let l of i)v?.observe(l);let I=l=>p=>{let g=ke(p.target);g&&A("interaction",{slotId:g.slotId,variantId:g.variantId,action:l})},C=I("click"),_=I("copy"),N=l=>{l.target.open&&I("expand")(l)},k=l=>{let p=l.target.closest?.("form")?.getAttribute("data-ge-goal");p&&b(p)},x=()=>{o.visibilityState==="hidden"&&m()},b=(l,p)=>{A("conversion",p===void 0?{goal:l}:{goal:l,value:p}),m()};o.addEventListener("click",C,!0),o.addEventListener("copy",_,!0),o.addEventListener("toggle",N,!0),o.addEventListener("submit",k,!0),o.addEventListener("visibilitychange",x),c.addEventListener("pagehide",m);let y=c.setInterval(m,Ce);return{conversion:b,flush:m,stop({discard:l=!1}={}){l&&(f.length=0),m(),v?.disconnect(),c.clearInterval(y),o.removeEventListener("click",C,!0),o.removeEventListener("copy",_,!0),o.removeEventListener("toggle",N,!0),o.removeEventListener("submit",k,!0),o.removeEventListener("visibilitychange",x),c.removeEventListener("pagehide",m)}}}function se(e){let t=e.getAttribute(d.slot);return t?{slotId:t,variantId:e.getAttribute(d.variant)||null,counterfactualVariantId:e.getAttribute(d.counterfactual)||null}:null}function ke(e){let t=e instanceof Element?e.closest(`[${d.slot}]`):null;return t?se(t):null}function be(e){try{let t=e.sessionStorage.getItem(D);if(t)return t;let n=oe(e);return e.sessionStorage.setItem(D,n),n}catch{return oe(e)}}function oe(e){if(typeof e.crypto.randomUUID=="function")return e.crypto.randomUUID();let t=e.crypto.getRandomValues(new Uint8Array(16));t[6]=t[6]&15|64,t[8]=t[8]&63|128;let n=Array.from(t,o=>o.toString(16).padStart(2,"0")).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`}function Pe(e,t,n,o){let r=JSON.stringify(n),s=o?"text/plain":"application/json";e.navigator.sendBeacon?.(t,new Blob([r],{type:s}))||e.fetch(t,{method:"POST",body:r,keepalive:!0,credentials:o?"omit":"same-origin",headers:{"content-type":s}}).catch(a=>console.warn("[ge] analytics beacon failed",a))}var S="ge_preview";var ie=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;function T(e){return typeof e=="string"&&ie.test(e)}function O(e){let t=JSON.stringify(Object.keys(e).sort().map(o=>[o,e[o]])),n=2166136261;for(let o=0;o<t.length;o++)n^=t.charCodeAt(o),n=Math.imul(n,16777619);return(n>>>0).toString(16).padStart(8,"0")}var ce="/api/v1/resolve",le="/api/v1/collect",Be=["matched","default","holdout"];function $(e){if(!M(e)||e.v!==1||!M(e.decisions)||!Number.isSafeInteger(e.configVersion)||typeof e.persist!="boolean")return null;let t=T(e.vid)?e.vid:void 0,n=e.persist&&t!==void 0,o=n&&typeof e.pin=="string"&&/^\d{1,9}\.\d{1,9}$/.test(e.pin)?e.pin:void 0,r={};for(let[a,c]of Object.entries(e.decisions)){let u=Le(c);u&&(r[a]=u)}let s=n&&typeof e.m=="string"&&e.m.length<=1024?e.m:void 0;return e.preview===!0?{v:1,configVersion:e.configVersion,persist:!1,preview:!0,decisions:r}:{v:1,configVersion:e.configVersion,persist:n,vid:t,...o?{pin:o}:{},decisions:r,...s?{m:s}:{}}}function Le(e){if(!M(e)||!M(e.content)||!ae(e.variantId)||!ae(e.counterfactualVariantId)||!Be.includes(e.reason))return null;let t={};for(let[n,o]of Object.entries(e.content))typeof o=="string"&&(t[n]=o);return{variantId:e.variantId,counterfactualVariantId:e.counterfactualVariantId,reason:e.reason,content:t}}function M(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function ae(e){return e===null||typeof e=="string"}var X="https://kharonlabs.com",U=500,Ne=5e3,R="ge_vid",De=365*24*60*60;function F(e){let t=e.root??document,n=t.defaultView??window,o=(e.apiBase??X).replace(/\/+$/,""),r=new Set,s=e.consent??"unknown",a=!1,c,u=null,f=!1,h=(i=!1)=>{u?.stop({discard:i}),u=null},A=()=>{a=!1,c=void 0,h(!0),Ke(t,R,e.cookieDomain);try{n.sessionStorage.removeItem(D),n.sessionStorage.removeItem(w)}catch{}},m=i=>{if(i.preview){f=!0,h(!0);return}i.persist&&i.vid&&s!=="denied"?(a=!0,c=i.m,K(t,R)!==i.vid&&$e(t,R,i.vid,e.cookieDomain),i.pin&&ue(n,w,i.pin)):(a||K(t,R))&&A()};return{get consent(){return s},async resolve(i,v=Date.now()+Ne){let I=K(t,R),C=pe(n,w),_=we(n),N={v:1,key:e.key,...T(I)?{vid:I}:{},...C?{pin:C}:{},url:_.url,referrer:t.referrer,slots:i.map(y=>({id:y.id,h:O(y.defaults)})),consent:s,gpc:n.navigator.globalPrivacyControl===!0},k=v-Date.now();if(k<=0||typeof n.fetch!="function")return null;let x=typeof AbortController=="function"?new AbortController:null,b;try{let y=n.fetch(o+ce,{method:"POST",body:JSON.stringify(N),credentials:"omit",signal:x?.signal}),l=new Promise(_e=>{b=setTimeout(()=>{x?.abort(),_e(null)},k)}),p=await Promise.race([y,l]);if(!p)return null;if(!p.ok)return console.warn(`[kharon] resolve answered ${p.status}; keeping defaults`),null;let g=$(await Promise.race([p.json(),l]));return!g||Date.now()>v?null:(g.preview&&_.token?ue(n,S,_.token):!g.preview&&_.token&&Me(n,S),m(g),g)}catch(y){return x?.signal.aborted||console.warn("[kharon] resolve failed; keeping defaults",y),null}finally{clearTimeout(b)}},adopt:m,track(i){h();let v=K(t,R);f||!a||!T(v)||(u=re({pageId:e.pageId??Y(n.location.pathname),configVersion:i,endpoint:o+le,root:t,crossOrigin:!0,envelope:I=>({v:2,key:e.key,vid:v,consent:s,events:I,...c?{m:c}:{}})}))},setConsent(i){if(i!==s){s=i,i==="denied"&&A();for(let v of r)v(i)}},onConsentChange(i){return r.add(i),()=>r.delete(i)},conversion(i,v){u?.conversion(i,v)},stop:()=>h()}}function Y(e){return e.split("/").map(n=>n.replace(/[^A-Za-z0-9_:-]/g,"")).filter(Boolean).join(".").slice(0,128)||"home"}function K(e,t){for(let n of e.cookie.split(";")){let[o,...r]=n.trim().split("=");if(o===t)try{return decodeURIComponent(r.join("="))}catch{return}}}function pe(e,t){try{return e.sessionStorage.getItem(t)}catch{return null}}function we(e){let t=e.location.href,n;try{n=new URL(t)}catch{return{url:t,token:null}}let o=n.searchParams.get(S);if(o)return{url:t,token:o};let r=pe(e,S);return r?(n.searchParams.set(S,r),{url:n.toString(),token:r}):{url:t,token:null}}function Me(e,t){try{e.sessionStorage.removeItem(t)}catch{}}function ue(e,t,n){try{e.sessionStorage.setItem(t,n)}catch{}}function de(e,t){let n=e.location?.protocol==="https:"?"; Secure":"";return`; Path=/; SameSite=Lax${t?`; Domain=${t}`:""}${n}`}function $e(e,t,n,o){e.cookie=`${t}=${encodeURIComponent(n)}; Max-Age=${De}${de(e,o)}`}function Ke(e,t,n){e.cookie=`${t}=; Max-Age=0${de(e,n)}`}var fe="data-ge-field",ge="data-ge-attr",G="kharon-af",me=`<style id="${G}">[${d.slot}]{visibility:hidden!important}</style><script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("${G}");s&&s.parentNode.removeChild(s)},500)<\/script>`,Ue=new Set(["href","src","action","formaction","poster","cite","xlink:href"]),Fe=/^(on|style$|srcdoc$|srcset$)/,H=new WeakMap,P="#text";function Ee(e,t){return t===null?(e.textContent??"").trim():e.getAttribute(t)}function Ie(e,t){let n=H.get(e);return n?.has(t??P)?n.get(t??P):Ee(e,t)}function ve(e,t,n){let o=H.get(e);o||H.set(e,o=new Map),o.has(t??P)||o.set(t??P,Ee(e,t)),t===null?e.textContent=n??"":n===null?e.removeAttribute(t):e.setAttribute(t,n)}function W(e){let t=new Map;for(let n of Se(e)){let o=n.getAttribute(d.slot);if(t.has(o))continue;let r={};for(let s of Re(n))s.field in r||(r[s.field]=Ie(s.el,s.attr)??"");t.set(o,{id:o,defaults:r})}return[...t.values()]}function ye(e,t){for(let n of Se(e)){let o=t.decisions[n.getAttribute(d.slot)];o&&J(n,o,e.baseURI)}}function J(e,t,n){for(let{el:o,field:r,attr:s}of Re(e)){let a=t.content[r];typeof a=="string"&&(s===null||Z(s,a,n))?ve(o,s,a):H.get(o)?.has(s??P)&&ve(o,s,Ie(o,s))}e.setAttribute(d.variant,t.variantId??""),e.setAttribute(d.counterfactual,t.counterfactualVariantId??""),e.setAttribute(d.reason,t.reason)}function B(e){e.getElementById(G)?.remove()}function q(e){let t=e.defaultView?.__kharonAf;return typeof t=="number"?t:void 0}function Z(e,t,n){let o=e.toLowerCase();if(Fe.test(o))return!1;if(!Ue.has(o))return!0;try{let{protocol:r}=new URL(t,n);return r==="http:"||r==="https:"}catch{return!1}}function Se(e){return Array.from(e.querySelectorAll(`[${d.slot}]`))}function Re(e){let t=[],n=[e,...Array.from(e.querySelectorAll(`[${fe}],[${ge}]`))];for(let o of n){if(o!==e&&o.closest(`[${d.slot}]`)!==e)continue;let r=o.getAttribute(fe);r&&t.push({el:o,field:r,attr:null});for(let s of(o.getAttribute(ge)??"").split(",")){let a=s.lastIndexOf(":"),c=s.slice(0,a).trim(),u=s.slice(a+1).trim();a>0&&c&&u&&t.push({el:o,field:u,attr:c})}}return t}var E=null,he;function Q(e){if(E)return console.warn("[kharon] init called twice; keeping the first instance"),E;let t=e.root??document,n=e.timeoutMs??U,o=F({...e,consent:he??e.consent}),r=async u=>{try{let f=await o.resolve(W(t),u);f&&ye(t,f),B(t),f&&o.track(f.configVersion)}catch(f){B(t),console.warn("[kharon] personalization failed; keeping defaults",f)}},a=new Promise(u=>{t.readyState!=="loading"?u():t.addEventListener("DOMContentLoaded",()=>u(),{once:!0})}).then(()=>r((q(t)??Date.now())+n));o.onConsentChange(u=>{u==="granted"&&(a=a.then(()=>r()))});let c={client:o,get ready(){return a},setConsent:o.setConsent,conversion:o.conversion,refresh(){return a=a.then(()=>r(Date.now()+n)),a},stop(){o.stop(),E===c&&(E=null)}};return E=c,c}function He(e){E?E.setConsent(e):he=e}function We(e,t){E?.conversion(e,t)}function qe(){return E?.refresh()??Promise.resolve()}var je=["granted","denied","unknown"],L=document.currentScript;window.kharon=ee;var Ae=L?.getAttribute("data-key");L&&Ae&&Q({key:Ae,apiBase:L.getAttribute("data-api")??new URL(L.src).origin,consent:je.find(e=>e===L.getAttribute("data-consent"))});})();
|
|
1
|
+
"use strict";(()=>{var be=Object.defineProperty;var Oe=(e,t)=>{for(var n in t)be(e,n,{get:t[n],enumerable:!0})};var ie={};Oe(ie,{ANTI_FLICKER_SNIPPET:()=>Ee,DEFAULT_API_BASE:()=>Q,DEFAULT_TIMEOUT_MS:()=>F,VISITOR_COOKIE:()=>E,antiFlickerStart:()=>j,applyDecision:()=>ne,conversion:()=>et,createClient:()=>q,hashSlotDefaults:()=>T,init:()=>re,isWritable:()=>oe,pageIdFor:()=>ee,parseResolveResponse:()=>$,refresh:()=>nt,reveal:()=>D,scanSlots:()=>W,setConsent:()=>Qe,signup:()=>tt});var G="signup";var X={gclid:"google",gbraid:"google",wbraid:"google",msclkid:"microsoft",fbclid:"meta",li_fat_id:"linkedin",ttclid:"tiktok",twclid:"x"},st=new Set(Object.values(X)),Ve=["source","medium","campaign","term","content"],Le="ge_co";function Y(e,t){let n={},o;try{o=new URL(e)}catch{return n}for(let i of Ve){let a=we(o.searchParams.get(`utm_${i}`));a&&(n[i]=a)}for(let[i,a]of Object.entries(X))if(o.searchParams.get(i)){n.clickId=a;break}o.searchParams.get(Le)&&(n.kharonLink=!0);let r=De(t);return r&&r!==se(o.hostname)&&(n.referrer=r),n}function we(e){let t=e?.replace(/[\u0000-\u001f\u007f]/g,"").trim();return t?t.slice(0,100):null}function De(e){if(!e)return null;try{let{protocol:t,hostname:n}=new URL(e);return t==="http:"||t==="https:"?se(n):null}catch{return null}}function se(e){return e.toLowerCase().replace(/^www\./,"")}var Z=1,B=50;var p={slot:"data-ge-slot",variant:"data-ge-variant",counterfactual:"data-ge-counterfactual",reason:"data-ge-reason",rung:"data-ge-rung"};var Me="/api/collect",U="ge_sid",K="ge_pin",ae=.5,Be=2e3;function le({pageId:e,configVersion:t,endpoint:n=Me,root:o=document,match:r,envelope:i=c=>({v:Z,events:c,...r?{m:r}:{}}),crossOrigin:a=!1}){let c=o.defaultView??window,{id:f,started:d}=Ke(c),m=[],b=new Set,R=(l,u)=>{m.push({type:l,sessionId:f,...u}),m.length>=B&&s()},s=()=>{for(;m.length;)$e(c,n,i(m.splice(0,B)),a)};R("page_view",d?{pageId:e,configVersion:t,touch:Y(c.location.href,o.referrer)}:{pageId:e,configVersion:t});let g=Array.from(o.querySelectorAll(`[${p.slot}]`)),h="IntersectionObserver"in c?new c.IntersectionObserver(l=>{for(let u of l){if(!u.isIntersecting||u.intersectionRatio<ae)continue;let v=ue(u.target);h?.unobserve(u.target),!(!v||b.has(v.slotId))&&(b.add(v.slotId),R("slot_impression",v))}},{threshold:ae}):null;for(let l of g)h?.observe(l);let x=l=>u=>{let v=Ue(u.target);v&&R("interaction",{slotId:v.slotId,variantId:v.variantId,action:l})},_=x("click"),N=x("copy"),O=l=>{l.target.open&&x("expand")(l)},C=l=>{let u=l.target.closest?.("form")?.getAttribute("data-ge-goal");u&&S(u)},V=()=>{o.visibilityState==="hidden"&&s()},S=(l,u)=>{R("conversion",u===void 0?{goal:l}:{goal:l,value:u}),s()};o.addEventListener("click",_,!0),o.addEventListener("copy",N,!0),o.addEventListener("toggle",O,!0),o.addEventListener("submit",C,!0),o.addEventListener("visibilitychange",V),c.addEventListener("pagehide",s);let M=c.setInterval(s,Be);return{conversion:S,flush:s,stop({discard:l=!1}={}){l&&(m.length=0),s(),h?.disconnect(),c.clearInterval(M),o.removeEventListener("click",_,!0),o.removeEventListener("copy",N,!0),o.removeEventListener("toggle",O,!0),o.removeEventListener("submit",C,!0),o.removeEventListener("visibilitychange",V),c.removeEventListener("pagehide",s)}}}function ue(e){let t=e.getAttribute(p.slot);return t?{slotId:t,variantId:e.getAttribute(p.variant)||null,counterfactualVariantId:e.getAttribute(p.counterfactual)||null,...e.getAttribute(p.rung)?{rung:e.getAttribute(p.rung)}:{}}:null}function Ue(e){let t=e instanceof Element?e.closest(`[${p.slot}]`):null;return t?ue(t):null}function Ke(e){try{let t=e.sessionStorage.getItem(U);if(t)return{id:t,started:!1};let n=ce(e);return e.sessionStorage.setItem(U,n),{id:n,started:!0}}catch{return{id:ce(e),started:!0}}}function ce(e){if(typeof e.crypto.randomUUID=="function")return e.crypto.randomUUID();let t=e.crypto.getRandomValues(new Uint8Array(16));t[6]=t[6]&15|64,t[8]=t[8]&63|128;let n=Array.from(t,o=>o.toString(16).padStart(2,"0")).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`}function $e(e,t,n,o){let r=JSON.stringify(n),i=o?"text/plain":"application/json";e.navigator.sendBeacon?.(t,new Blob([r],{type:i}))||e.fetch(t,{method:"POST",body:r,keepalive:!0,credentials:o?"omit":"same-origin",headers:{"content-type":i}}).catch(a=>console.warn("[ge] analytics beacon failed",a))}var y="ge_preview";var pe=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;function A(e){return typeof e=="string"&&pe.test(e)}function T(e){let t=JSON.stringify(Object.keys(e).sort().map(o=>[o,e[o]])),n=2166136261;for(let o=0;o<t.length;o++)n^=t.charCodeAt(o),n=Math.imul(n,16777619);return(n>>>0).toString(16).padStart(8,"0")}var de="/api/v1/resolve",ge="/api/v1/collect",He=["matched","default","holdout"],Fe=["exact","near","segment","default"];function $(e){if(!L(e)||e.v!==1||!L(e.decisions)||!Number.isSafeInteger(e.configVersion)||typeof e.persist!="boolean")return null;let t=A(e.vid)?e.vid:void 0,n=e.persist&&t!==void 0,o=n&&typeof e.pin=="string"&&/^\d{1,9}\.\d{1,9}$/.test(e.pin)?e.pin:void 0,r={};for(let[a,c]of Object.entries(e.decisions)){let f=qe(c);f&&(r[a]=f)}let i=n&&typeof e.m=="string"&&e.m.length<=1024?e.m:void 0;if(e.preview===!0){let a=L(e.explain)?e.explain:void 0;return{v:1,configVersion:e.configVersion,persist:!1,preview:!0,decisions:r,...a?{explain:a}:{}}}return{v:1,configVersion:e.configVersion,persist:n,vid:t,...o?{pin:o}:{},decisions:r,...i?{m:i}:{}}}function qe(e){if(!L(e)||!L(e.content)||!fe(e.variantId)||!fe(e.counterfactualVariantId)||!He.includes(e.reason))return null;let t={};for(let[o,r]of Object.entries(e.content))typeof r=="string"&&(t[o]=r);let n=Fe.includes(e.rung)?e.rung:void 0;return{variantId:e.variantId,counterfactualVariantId:e.counterfactualVariantId,reason:e.reason,...n&&{rung:n},content:t}}function L(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function fe(e){return e===null||typeof e=="string"}var Q="https://kharonlabs.com",F=500,ze=5e3,E="ge_vid",We=365*24*60*60;function q(e){let t=e.root??document,n=t.defaultView??window,o=(e.apiBase??Q).replace(/\/+$/,""),r=new Set,i=e.consent??"unknown",a=!1,c,f=null,d=!1,m=(s=!1)=>{f?.stop({discard:s}),f=null},b=()=>{a=!1,c=void 0,m(!0),Ye(t,E,e.cookieDomain);try{n.sessionStorage.removeItem(U),n.sessionStorage.removeItem(K)}catch{}},R=s=>{if(s.preview){d=!0,m(!0),s.explain&&n.console?.info("[kharon] debug link: why this page shows what it does",s.explain);return}s.persist&&s.vid&&i!=="denied"?(a=!0,c=s.m,H(t,E)!==s.vid&&Xe(t,E,s.vid,e.cookieDomain),s.pin&&me(n,K,s.pin)):(a||H(t,E))&&b()};return{get consent(){return i},async resolve(s,g=Date.now()+ze){let h=H(t,E),x=ve(n,K),_=je(n),N={v:1,key:e.key,...A(h)?{vid:h}:{},...x?{pin:x}:{},url:_.url,referrer:t.referrer,slots:s.map(S=>({id:S.id,h:T(S.defaults)})),consent:i,gpc:n.navigator.globalPrivacyControl===!0},O=g-Date.now();if(O<=0||typeof n.fetch!="function")return null;let C=typeof AbortController=="function"?new AbortController:null,V;try{let S=n.fetch(o+de,{method:"POST",body:JSON.stringify(N),credentials:"omit",signal:C?.signal}),M=new Promise(v=>{V=setTimeout(()=>{C?.abort(),v(null)},O)}),l=await Promise.race([S,M]);if(!l)return null;if(!l.ok)return console.warn(`[kharon] resolve answered ${l.status}; keeping defaults`),null;let u=$(await Promise.race([l.json(),M]));return!u||Date.now()>g?null:(u.preview&&_.token?me(n,y,_.token):!u.preview&&_.token&&Ge(n,y),R(u),u)}catch(S){return C?.signal.aborted||console.warn("[kharon] resolve failed; keeping defaults",S),null}finally{clearTimeout(V)}},adopt:R,track(s){m();let g=H(t,E);d||!a||!A(g)||(f=le({pageId:e.pageId??ee(n.location.pathname),configVersion:s,endpoint:o+ge,root:t,crossOrigin:!0,envelope:h=>({v:2,key:e.key,vid:g,consent:i,events:h,...c?{m:c}:{}})}))},setConsent(s){if(s!==i){i=s,s==="denied"&&b();for(let g of r)g(s)}},onConsentChange(s){return r.add(s),()=>r.delete(s)},conversion(s,g){f?.conversion(s,g)},stop:()=>m()}}function ee(e){return e.split("/").map(n=>n.replace(/[^A-Za-z0-9_:-]/g,"")).filter(Boolean).join(".").slice(0,128)||"home"}function H(e,t){for(let n of e.cookie.split(";")){let[o,...r]=n.trim().split("=");if(o===t)try{return decodeURIComponent(r.join("="))}catch{return}}}function ve(e,t){try{return e.sessionStorage.getItem(t)}catch{return null}}function je(e){let t=e.location.href,n;try{n=new URL(t)}catch{return{url:t,token:null}}let o=n.searchParams.get(y);if(o)return{url:t,token:o};let r=ve(e,y);return r?(n.searchParams.set(y,r),{url:n.toString(),token:r}):{url:t,token:null}}function Ge(e,t){try{e.sessionStorage.removeItem(t)}catch{}}function me(e,t,n){try{e.sessionStorage.setItem(t,n)}catch{}}function Ie(e,t){let n=e.location?.protocol==="https:"?"; Secure":"";return`; Path=/; SameSite=Lax${t?`; Domain=${t}`:""}${n}`}function Xe(e,t,n,o){e.cookie=`${t}=${encodeURIComponent(n)}; Max-Age=${We}${Ie(e,o)}`}function Ye(e,t,n){e.cookie=`${t}=; Max-Age=0${Ie(e,n)}`}var Se="data-ge-field",he="data-ge-attr",te="kharon-af",Ee=`<style id="${te}">[${p.slot}]{visibility:hidden!important}</style><script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("${te}");s&&s.parentNode.removeChild(s)},500)<\/script>`,Ze=new Set(["href","src","action","formaction","poster","cite","xlink:href"]),Je=/^(on|style$|srcdoc$|srcset$)/,z=new WeakMap,w="#text";function Re(e,t){return t===null?(e.textContent??"").trim():e.getAttribute(t)}function _e(e,t){let n=z.get(e);return n?.has(t??w)?n.get(t??w):Re(e,t)}function ye(e,t,n){let o=z.get(e);o||z.set(e,o=new Map),o.has(t??w)||o.set(t??w,Re(e,t)),t===null?e.textContent=n??"":n===null?e.removeAttribute(t):e.setAttribute(t,n)}function W(e){let t=new Map;for(let n of ke(e)){let o=n.getAttribute(p.slot);if(t.has(o))continue;let r={};for(let i of xe(n))i.field in r||(r[i.field]=_e(i.el,i.attr)??"");t.set(o,{id:o,defaults:r})}return[...t.values()]}function Ae(e,t){for(let n of ke(e)){let o=t.decisions[n.getAttribute(p.slot)];o&&ne(n,o,e.baseURI)}}function ne(e,t,n){for(let{el:o,field:r,attr:i}of xe(e)){let a=t.content[r];typeof a=="string"&&(i===null||oe(i,a,n))?ye(o,i,a):z.get(o)?.has(i??w)&&ye(o,i,_e(o,i))}e.setAttribute(p.variant,t.variantId??""),e.setAttribute(p.counterfactual,t.counterfactualVariantId??""),e.setAttribute(p.reason,t.reason),t.rung?e.setAttribute(p.rung,t.rung):e.removeAttribute(p.rung)}function D(e){e.getElementById(te)?.remove()}function j(e){let t=e.defaultView?.__kharonAf;return typeof t=="number"?t:void 0}function oe(e,t,n){let o=e.toLowerCase();if(Je.test(o))return!1;if(!Ze.has(o))return!0;try{let{protocol:r}=new URL(t,n);return r==="http:"||r==="https:"}catch{return!1}}function ke(e){return Array.from(e.querySelectorAll(`[${p.slot}]`))}function xe(e){let t=[],n=[e,...Array.from(e.querySelectorAll(`[${Se}],[${he}]`))];for(let o of n){if(o!==e&&o.closest(`[${p.slot}]`)!==e)continue;let r=o.getAttribute(Se);r&&t.push({el:o,field:r,attr:null});for(let i of(o.getAttribute(he)??"").split(",")){let a=i.lastIndexOf(":"),c=i.slice(0,a).trim(),f=i.slice(a+1).trim();a>0&&c&&f&&t.push({el:o,field:f,attr:c})}}return t}var I=null,Ce;function re(e){if(I)return console.warn("[kharon] init called twice; keeping the first instance"),I;let t=e.root??document,n=e.timeoutMs??F,o=q({...e,consent:Ce??e.consent}),r=async f=>{try{let d=await o.resolve(W(t),f);d&&Ae(t,d),D(t),d&&o.track(d.configVersion)}catch(d){D(t),console.warn("[kharon] personalization failed; keeping defaults",d)}},a=new Promise(f=>{t.readyState!=="loading"?f():t.addEventListener("DOMContentLoaded",()=>f(),{once:!0})}).then(()=>r((j(t)??Date.now())+n));o.onConsentChange(f=>{f==="granted"&&(a=a.then(()=>r()))});let c={client:o,get ready(){return a},setConsent:o.setConsent,conversion:o.conversion,refresh(){return a=a.then(()=>r(Date.now()+n)),a},stop(){o.stop(),I===c&&(I=null)}};return I=c,c}function Qe(e){I?I.setConsent(e):Ce=e}function et(e,t){I?.conversion(e,t)}function tt(){I?.conversion(G)}function nt(){return I?.refresh()??Promise.resolve()}var ot=["granted","denied","unknown"],P=document.currentScript;window.kharon=ie;var Te=P?.getAttribute("data-key");P&&Te&&re({key:Te,apiBase:P.getAttribute("data-api")??new URL(P.src).origin,consent:ot.find(e=>e===P.getAttribute("data-consent"))});})();
|
package/package.json
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kharon-labs/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Personalize slots on any website with Kharon Labs: swaps in the variant for each visitor and measures lift against a holdout.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/alexarmenciu/growth-engine.git",
|
|
9
|
-
"directory": "packages/browser"
|
|
10
|
-
},
|
|
11
6
|
"homepage": "https://kharonlabs.com",
|
|
12
7
|
"type": "module",
|
|
13
8
|
"sideEffects": false,
|