@appright/funnelshift 0.0.0-stage → 0.16.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/bin/funnel-kit.js +2 -0
- package/dist/anchors.d.ts +4 -0
- package/dist/anchors.js +94 -0
- package/dist/answers.d.ts +11 -0
- package/dist/answers.js +100 -0
- package/dist/attribution.d.ts +34 -0
- package/dist/attribution.js +237 -0
- package/dist/autotrack.d.ts +4 -0
- package/dist/autotrack.js +13 -0
- package/dist/backnav.d.ts +15 -0
- package/dist/backnav.js +73 -0
- package/dist/blocks/Choice.d.ts +19 -0
- package/dist/blocks/Choice.js +38 -0
- package/dist/blocks/Cta.d.ts +7 -0
- package/dist/blocks/Cta.js +8 -0
- package/dist/blocks/Hero.d.ts +10 -0
- package/dist/blocks/Hero.js +48 -0
- package/dist/blocks/MultiChoice.d.ts +13 -0
- package/dist/blocks/MultiChoice.js +47 -0
- package/dist/blocks/Section.d.ts +6 -0
- package/dist/blocks/Section.js +4 -0
- package/dist/bootstrap.d.ts +33 -0
- package/dist/bootstrap.js +19 -0
- package/dist/bucket.d.ts +13 -0
- package/dist/bucket.js +41 -0
- package/dist/cli/args.d.ts +5 -0
- package/dist/cli/args.js +18 -0
- package/dist/cli/build.d.ts +6 -0
- package/dist/cli/build.js +200 -0
- package/dist/cli/extract.d.ts +32 -0
- package/dist/cli/extract.js +253 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +40 -0
- package/dist/cli/init.d.ts +5 -0
- package/dist/cli/init.js +67 -0
- package/dist/cli/lint.d.ts +25 -0
- package/dist/cli/lint.js +535 -0
- package/dist/cli/upload.d.ts +32 -0
- package/dist/cli/upload.js +100 -0
- package/dist/clicks.d.ts +7 -0
- package/dist/clicks.js +102 -0
- package/dist/content.d.ts +7 -0
- package/dist/content.js +60 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +52 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +90 -0
- package/dist/experiments.d.ts +4 -0
- package/dist/experiments.js +24 -0
- package/dist/exposure.d.ts +4 -0
- package/dist/exposure.js +35 -0
- package/dist/functions.d.ts +37 -0
- package/dist/functions.js +24 -0
- package/dist/funnel.d.ts +64 -0
- package/dist/funnel.js +433 -0
- package/dist/handoff.d.ts +8 -0
- package/dist/handoff.js +66 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/nav.d.ts +19 -0
- package/dist/nav.js +14 -0
- package/dist/offer.d.ts +3 -0
- package/dist/offer.js +7 -0
- package/dist/pixel.d.ts +36 -0
- package/dist/pixel.js +173 -0
- package/dist/prerender.d.ts +2 -0
- package/dist/prerender.js +16 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +88 -0
- package/dist/sha256.d.ts +2 -0
- package/dist/sha256.js +65 -0
- package/dist/signals.d.ts +18 -0
- package/dist/signals.js +300 -0
- package/dist/tracker.d.ts +80 -0
- package/dist/tracker.js +446 -0
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +12 -0
- package/dist/vitals.d.ts +4 -0
- package/dist/vitals.js +104 -0
- package/package.json +29 -4
- package/src/blocks/blocks.css +364 -0
- package/templates/init/CLAUDE.md +10 -0
- package/templates/init/funnels/welcome/index.tsx +129 -0
- package/templates/init/gitignore +5 -0
- package/templates/init/package.json.tmpl +20 -0
- package/templates/init/theme.css +77 -0
- package/templates/init/tsconfig.json +7 -0
- package/README.md +0 -3
package/dist/tracker.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { uuidv7 } from './uuid.js';
|
|
2
|
+
import { capParams, CLICK_IDS, MAX_PARAM_KEY, MAX_PARAM_VALUE } from './attribution.js';
|
|
3
|
+
import { PROP_KEY, MAX_PROPS, cut } from './answers.js';
|
|
4
|
+
import { batchContext } from './context.js';
|
|
5
|
+
const SESSION_GAP = 30 * 60 * 1000;
|
|
6
|
+
const MAX_QUEUE = 1000;
|
|
7
|
+
const MAX_BATCH = 100; // the server rejects bigger batches
|
|
8
|
+
const MAX_BODY = 48 * 1024; // keepalive/sendBeacon bodies are limited to 64 KB, and 100 fat events can exceed the server's 256 KB
|
|
9
|
+
const MAX_AGE = 7 * 24 * 3600 * 1000; // the server would replace a timestamp this far off anyway; such events are just dropped
|
|
10
|
+
const BACKOFF_BASE = 5000;
|
|
11
|
+
const BACKOFF_MAX = 60000;
|
|
12
|
+
const enc = new TextEncoder();
|
|
13
|
+
const VARIANT_KEY = /^[a-z][a-z0-9_]{0,31}$/;
|
|
14
|
+
const NAME = /^\$?[a-z][a-z0-9_]{0,63}$/;
|
|
15
|
+
const STEP = /^[a-z0-9_-]{0,64}$/;
|
|
16
|
+
const ARM = /^[a-z0-9_-]{1,32}$/;
|
|
17
|
+
const HOST = /^[a-z0-9]([a-z0-9.-]{0,252})?$/;
|
|
18
|
+
// The user id of the product (identify(), kit 0.11.0): what ingest accepts for the event's user_id (128 characters, no '@' — an
|
|
19
|
+
// email is not an id — no control characters). Mirrored by server/internal/ingest ValidUserID.
|
|
20
|
+
export const MAX_USER_ID = 128;
|
|
21
|
+
export function validUserId(u) {
|
|
22
|
+
return typeof u === 'string' && u !== '' && [...u].length <= MAX_USER_ID && !u.includes('@') && !/[\u0000-\u001f\u007f-\u009f]/.test(u);
|
|
23
|
+
}
|
|
24
|
+
const UTM_KEYS = ['source', 'medium', 'campaign', 'content', 'term'];
|
|
25
|
+
const CLICK_TYPES = ['', ...new Set(CLICK_IDS.map(([p]) => p.toLowerCase()))];
|
|
26
|
+
const NETWORKS = new Set(CLICK_IDS.map(([, n]) => n));
|
|
27
|
+
const SEG = /^[a-z0-9._-]{1,64}$/;
|
|
28
|
+
// The server answers these for good: 202 accepted, 400 malformed (a retry cannot fix it), 401 unknown key,
|
|
29
|
+
// 403 bad origin, 413 too large. Everything else (503, other 5xx, 429, network errors) is retried later.
|
|
30
|
+
const FINAL = new Set([202, 400, 401, 403, 413]);
|
|
31
|
+
// An outage of ingest, not a refusal: these send the same batch to the fallback ingest.
|
|
32
|
+
const OUTAGE = (status) => status === 0 || status === 429 || status >= 500;
|
|
33
|
+
// Degraded mode (after a fallback 202: ingest is likely down): ingest is probed at most once a minute, batches go to the fallback
|
|
34
|
+
// every 30 s or when maxBatch events wait, and at most 20 per page load; the first 202 from ingest ends it.
|
|
35
|
+
const PRIMARY_TIMEOUT = 8000;
|
|
36
|
+
const PROBE_EVERY = 60_000;
|
|
37
|
+
const DEGRADED_EVERY = 30_000;
|
|
38
|
+
const MAX_FALLBACK_SENDS = 20;
|
|
39
|
+
// Shared by every tab and reload of this browser (localStorage): until this time (ms) ingest is taken for down, so a new page
|
|
40
|
+
// load starts degraded instead of waiting up to 8 s on ingest first. Set for a minute on each fallback 202, cleared by any
|
|
41
|
+
// answer of ingest that is not an outage.
|
|
42
|
+
const DEGRADED_KEY = 'af_degraded_until';
|
|
43
|
+
// Props in the shape the server accepts (spec "all events" §4): at most 128, the name [a-z][a-z0-9_]{0,63}, the value a string
|
|
44
|
+
// of at most 1000 characters (a longer one is cut, not lost), a number or a boolean. Answers may be free text, emails and
|
|
45
|
+
// phones: the owner collects them on purpose, so nothing is filtered by content. Anything else is dropped here, because the
|
|
46
|
+
// server would reject the whole event for it.
|
|
47
|
+
export function safeProps(p) {
|
|
48
|
+
const out = {};
|
|
49
|
+
let n = 0;
|
|
50
|
+
for (const [k, v] of Object.entries(p ?? {})) {
|
|
51
|
+
if (n >= MAX_PROPS)
|
|
52
|
+
break;
|
|
53
|
+
if (!PROP_KEY.test(k))
|
|
54
|
+
continue;
|
|
55
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
56
|
+
out[k] = String(v);
|
|
57
|
+
else if (typeof v === 'string')
|
|
58
|
+
out[k] = cut(v).text;
|
|
59
|
+
else
|
|
60
|
+
continue;
|
|
61
|
+
n++;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
// The server rejects only an event with a bad field (it answers 202 with accepted/rejected counts), but such an event is lost,
|
|
66
|
+
// so attribution is cleaned to what the server accepts. The landing fields (kit 0.7) are cut or emptied one by one, as the
|
|
67
|
+
// server does with them: a bad one never costs the event.
|
|
68
|
+
function cleanAttribution(a) {
|
|
69
|
+
const utm = {};
|
|
70
|
+
for (const k of UTM_KEYS) {
|
|
71
|
+
const v = a?.utm?.[k];
|
|
72
|
+
if (typeof v === 'string' && v && v.length <= 128 && !v.includes('@'))
|
|
73
|
+
utm[k] = v;
|
|
74
|
+
}
|
|
75
|
+
const type = a && typeof a.click_id_type === 'string' && CLICK_TYPES.includes(a.click_id_type) ? a.click_id_type : '';
|
|
76
|
+
const id = type && typeof a?.click_id === 'string' && a.click_id.length <= 256 && !a.click_id.includes('@') ? a.click_id : '';
|
|
77
|
+
const host = (a?.referrer_host ?? '').toLowerCase();
|
|
78
|
+
const seg = (v) => (typeof v === 'string' && SEG.test(v.toLowerCase()) ? v.toLowerCase() : '');
|
|
79
|
+
const path = typeof a?.landing_path === 'string' && a.landing_path.startsWith('/') && [...a.landing_path].length <= 512 && !a.landing_path.includes('@') ? a.landing_path : '';
|
|
80
|
+
const fit = [];
|
|
81
|
+
for (const [k, v] of Object.entries(a?.landing_params ?? {})) {
|
|
82
|
+
if (!k || [...k].length > MAX_PARAM_KEY || typeof v !== 'string')
|
|
83
|
+
continue;
|
|
84
|
+
fit.push([k, [...v].length > MAX_PARAM_VALUE ? [...v].slice(0, MAX_PARAM_VALUE).join('') : v]);
|
|
85
|
+
}
|
|
86
|
+
const params = capParams(fit); // 50 keys and 6 KB, the rest counted in $dropped
|
|
87
|
+
const segs = Array.isArray(a?.path_segs) ? a.path_segs.map(seg).filter(Boolean).slice(0, 16) : [];
|
|
88
|
+
const creator = seg(a?.creator), entry = seg(a?.entry_creator);
|
|
89
|
+
const referrer = typeof a?.referrer === 'string' && /^(https?|android-app):\/\/[^/?#@\s]+(\/[^?#\s]*)?$/.test(a.referrer) && [...a.referrer].length <= 1024 ? a.referrer : '';
|
|
90
|
+
const landing = { landing_path: path, landing_params: params, path_segs: segs, referrer,
|
|
91
|
+
click_network: typeof a?.click_network === 'string' && NETWORKS.has(a.click_network) ? a.click_network : '',
|
|
92
|
+
creator, channel: creator ? seg(a?.channel) : '', entry_creator: entry, entry_channel: entry ? seg(a?.entry_channel) : '' };
|
|
93
|
+
// An empty landing field is left out (the server reads a missing one as empty): a batch of 100 plain events stays as small
|
|
94
|
+
// as before kit 0.7.
|
|
95
|
+
for (const [k, v] of Object.entries(landing))
|
|
96
|
+
if (v === '' || (Array.isArray(v) ? !v.length : typeof v === 'object' && !Object.keys(v).length))
|
|
97
|
+
delete landing[k];
|
|
98
|
+
return { utm, click_id_type: id ? type : '', click_id: id, referrer_host: HOST.test(host) && host.length <= 255 ? host : '', ...landing };
|
|
99
|
+
}
|
|
100
|
+
function cleanVariants(v) {
|
|
101
|
+
const out = {};
|
|
102
|
+
if (!v || typeof v !== 'object')
|
|
103
|
+
return out;
|
|
104
|
+
for (const [k, arm] of Object.entries(v))
|
|
105
|
+
if (typeof arm === 'string' && (Object.keys(out).length < 16 && VARIANT_KEY.test(k) && ARM.test(arm)))
|
|
106
|
+
out[k] = arm;
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
// unmark is the event as sent: without the queue's own _b mark.
|
|
110
|
+
function unmark(e) {
|
|
111
|
+
if (!('_b' in e))
|
|
112
|
+
return e;
|
|
113
|
+
const { _b, ...rest } = e;
|
|
114
|
+
void _b;
|
|
115
|
+
return rest;
|
|
116
|
+
}
|
|
117
|
+
export class Tracker {
|
|
118
|
+
o;
|
|
119
|
+
qkey;
|
|
120
|
+
stopKey;
|
|
121
|
+
projectStopKey;
|
|
122
|
+
userKey;
|
|
123
|
+
memUser = ''; // the user id of this page when storage is blocked
|
|
124
|
+
halted = false; // stopTracking() in this tab, also when sessionStorage is blocked
|
|
125
|
+
isDegraded = false;
|
|
126
|
+
nextProbe = 0; // degraded: no request to ingest before this
|
|
127
|
+
lastFallback = 0; // degraded: when the last batch went to the fallback
|
|
128
|
+
fallbackSends = 0; // this page load
|
|
129
|
+
timer;
|
|
130
|
+
inflight = Promise.resolve();
|
|
131
|
+
failures = 0;
|
|
132
|
+
retryAt = 0;
|
|
133
|
+
onHide = () => { void this.flush(true); };
|
|
134
|
+
onVisibility = () => { if (document.visibilityState === 'hidden')
|
|
135
|
+
void this.flush(true); };
|
|
136
|
+
constructor(o) {
|
|
137
|
+
this.o = {
|
|
138
|
+
storage: localStorage, session: sessionStorage, fetchImpl: fetch.bind(globalThis),
|
|
139
|
+
beacon: (u, b) => navigator.sendBeacon?.(u, new Blob([b], { type: 'text/plain' })) ?? false,
|
|
140
|
+
maxBatch: 20, intervalMs: 5000, now: Date.now, fallbackUrl: null, project: '', primaryTimeoutMs: PRIMARY_TIMEOUT, variants: {}, context: batchContext, geo: '', ...o,
|
|
141
|
+
};
|
|
142
|
+
this.qkey = 'af_q:' + o.base.funnel_id;
|
|
143
|
+
this.stopKey = 'af_stop:' + o.base.funnel_id;
|
|
144
|
+
this.projectStopKey = 'af_stop_project:' + this.o.project;
|
|
145
|
+
this.userKey = 'af_user:' + (this.o.project || o.base.funnel_id);
|
|
146
|
+
// A later visit after a project-wide stop: what this funnel still had queued from before is not sent either.
|
|
147
|
+
if (this.projectStopped())
|
|
148
|
+
this.save([]);
|
|
149
|
+
const until = this.sharedDegradedUntil();
|
|
150
|
+
if (until > this.o.now()) {
|
|
151
|
+
this.isDegraded = true;
|
|
152
|
+
this.nextProbe = until;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
sharedDegradedUntil() {
|
|
156
|
+
try {
|
|
157
|
+
const v = Number(this.o.storage.getItem(DEGRADED_KEY));
|
|
158
|
+
return Number.isFinite(v) ? v : 0;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
shareDegraded(until) {
|
|
165
|
+
try {
|
|
166
|
+
if (until === null)
|
|
167
|
+
this.o.storage.removeItem(DEGRADED_KEY);
|
|
168
|
+
else
|
|
169
|
+
this.o.storage.setItem(DEGRADED_KEY, String(until));
|
|
170
|
+
}
|
|
171
|
+
catch { /* storage unavailable: this page only */ }
|
|
172
|
+
}
|
|
173
|
+
projectStopped() {
|
|
174
|
+
if (!this.o.project)
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
return this.o.storage.getItem(this.projectStopKey) === '1';
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// stopTracking() was called for this funnel in this tab (this page or one before a reload), or for the whole project in
|
|
184
|
+
// this browser: nothing is sent any more.
|
|
185
|
+
stopped() {
|
|
186
|
+
if (this.halted)
|
|
187
|
+
return true;
|
|
188
|
+
try {
|
|
189
|
+
if (this.o.session.getItem(this.stopKey) === '1')
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
catch { /* storage blocked */ }
|
|
193
|
+
return this.projectStopped();
|
|
194
|
+
}
|
|
195
|
+
// The user id of the product this browser was identified as (localStorage per project, so every funnel and later visit of the
|
|
196
|
+
// project carries it), '' for an anonymous visitor.
|
|
197
|
+
user() {
|
|
198
|
+
try {
|
|
199
|
+
const v = this.o.storage.getItem(this.userKey);
|
|
200
|
+
if (v !== null)
|
|
201
|
+
return validUserId(v) ? v : '';
|
|
202
|
+
}
|
|
203
|
+
catch { /* storage blocked: this page's memory */ }
|
|
204
|
+
return this.memUser;
|
|
205
|
+
}
|
|
206
|
+
setUser(id) {
|
|
207
|
+
if (!validUserId(id))
|
|
208
|
+
return;
|
|
209
|
+
this.memUser = id;
|
|
210
|
+
try {
|
|
211
|
+
this.o.storage.setItem(this.userKey, id);
|
|
212
|
+
}
|
|
213
|
+
catch { /* storage unavailable: this page only */ }
|
|
214
|
+
}
|
|
215
|
+
clearUser() {
|
|
216
|
+
this.memUser = '';
|
|
217
|
+
try {
|
|
218
|
+
this.o.storage.removeItem(this.userKey);
|
|
219
|
+
}
|
|
220
|
+
catch { /* storage unavailable */ }
|
|
221
|
+
}
|
|
222
|
+
// Degraded mode: the last batch went through the fallback and ingest has not answered 202 since.
|
|
223
|
+
degraded() { return this.isDegraded; }
|
|
224
|
+
// Stops this funnel's tracking in this tab for good (until the tab closes): clears its queue, sends nothing more, and gives
|
|
225
|
+
// server() no session any more. final, given, is sent once, right now, as an event nobody can tie to this visitor: a fresh
|
|
226
|
+
// random visitor_id and session_id, no attribution, no step, no answers, only its own props. A second stop sends nothing.
|
|
227
|
+
// scope 'project' also keeps the stop in localStorage for every funnel of the project in this browser.
|
|
228
|
+
async halt(final, scope = 'tab') {
|
|
229
|
+
const already = this.stopped();
|
|
230
|
+
this.halted = true;
|
|
231
|
+
try {
|
|
232
|
+
this.o.session.setItem(this.stopKey, '1');
|
|
233
|
+
}
|
|
234
|
+
catch { /* storage unavailable: held in memory for this page */ }
|
|
235
|
+
if (scope === 'project' && this.o.project) {
|
|
236
|
+
try {
|
|
237
|
+
this.o.storage.setItem(this.projectStopKey, '1');
|
|
238
|
+
}
|
|
239
|
+
catch { /* storage unavailable: this tab only */ }
|
|
240
|
+
}
|
|
241
|
+
this.stop();
|
|
242
|
+
this.save([]);
|
|
243
|
+
if (!final || already)
|
|
244
|
+
return;
|
|
245
|
+
if (!NAME.test(final.name)) {
|
|
246
|
+
console.warn(`funnel-kit: final event "${final.name}" has an invalid name, not sent`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const { ingestUrl, key, base } = this.o;
|
|
250
|
+
if (!ingestUrl)
|
|
251
|
+
return;
|
|
252
|
+
const now = this.o.now();
|
|
253
|
+
const ev = { funnel_id: base.funnel_id, build_id: base.build_id, release_id: base.release_id, env: base.env,
|
|
254
|
+
url_path: base.url_path.startsWith('/') ? base.url_path.slice(0, 512) : '', visitor_id: crypto.randomUUID(),
|
|
255
|
+
session_id: crypto.randomUUID(), event_id: uuidv7(now), name: final.name, step_id: '', props: safeProps(final.props),
|
|
256
|
+
variants: {}, ts_client: now, user_id: '', ...cleanAttribution() };
|
|
257
|
+
// keepalive: an age gate may close or leave the page right after; one small event fits its 64 KB. It is the one send
|
|
258
|
+
// allowed after the stop, and it is not counted against the fallback's limit.
|
|
259
|
+
const body = JSON.stringify({ key, events: [ev], sent_at: now });
|
|
260
|
+
const status = await this.post(ingestUrl, body, true, true);
|
|
261
|
+
if (!FINAL.has(status) && OUTAGE(status) && this.o.fallbackUrl)
|
|
262
|
+
await this.post(this.o.fallbackUrl, body, false, true);
|
|
263
|
+
}
|
|
264
|
+
// The device's ctx for this batch; a failing reader gives none, it never costs the batch.
|
|
265
|
+
context() { try {
|
|
266
|
+
return this.o.context();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return {};
|
|
270
|
+
} }
|
|
271
|
+
// One POST; its status, 0 for no answer. Ingest (primary) is given up after primaryTimeoutMs; the fallback has no timeout.
|
|
272
|
+
async post(url, body, primary, keepalive = false) {
|
|
273
|
+
const init = { method: 'POST', body, headers: { 'Content-Type': 'text/plain' } };
|
|
274
|
+
if (keepalive)
|
|
275
|
+
init.keepalive = true;
|
|
276
|
+
if (primary && typeof AbortSignal.timeout === 'function')
|
|
277
|
+
init.signal = AbortSignal.timeout(this.o.primaryTimeoutMs);
|
|
278
|
+
try {
|
|
279
|
+
return (await this.o.fetchImpl(url, init)).status;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
queue() { try {
|
|
286
|
+
return JSON.parse(this.o.storage.getItem(this.qkey) ?? '[]');
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return [];
|
|
290
|
+
} }
|
|
291
|
+
// Storage can be full or blocked (private mode): analytics must never break the funnel, so a failed save is dropped.
|
|
292
|
+
save(q) { try {
|
|
293
|
+
this.o.storage.setItem(this.qkey, JSON.stringify(q.slice(-MAX_QUEUE)));
|
|
294
|
+
}
|
|
295
|
+
catch { /* storage unavailable */ } }
|
|
296
|
+
sessionId() {
|
|
297
|
+
const now = this.o.now();
|
|
298
|
+
let s = null;
|
|
299
|
+
try {
|
|
300
|
+
s = JSON.parse(this.o.session.getItem('af_sid') ?? 'null');
|
|
301
|
+
}
|
|
302
|
+
catch { /* corrupt */ }
|
|
303
|
+
if (!s || now - s.last > SESSION_GAP)
|
|
304
|
+
s = { id: uuidv7(now), last: now };
|
|
305
|
+
s.last = now;
|
|
306
|
+
try {
|
|
307
|
+
this.o.session.setItem('af_sid', JSON.stringify(s));
|
|
308
|
+
}
|
|
309
|
+
catch { /* storage unavailable */ }
|
|
310
|
+
return s.id;
|
|
311
|
+
}
|
|
312
|
+
// The current session id (the one the next event will carry); server() sends it as X-Session. Asking counts as activity.
|
|
313
|
+
// After stopTracking() there is none: the router then records no $fn_call for this tab.
|
|
314
|
+
session() { return this.stopped() ? '' : this.sessionId(); }
|
|
315
|
+
// The event's event_id, '' when it was not tracked (stopped, an invalid name).
|
|
316
|
+
track(name, props, stepId = '', variants = this.o.variants) {
|
|
317
|
+
if (this.stopped())
|
|
318
|
+
return '';
|
|
319
|
+
// The server would reject an event with an invalid name or step id (counted as rejected), so it never enters the queue.
|
|
320
|
+
if (!NAME.test(name) || !STEP.test(stepId)) {
|
|
321
|
+
console.warn(`funnel-kit: event "${name}" (step "${stepId}") has an invalid name, not tracked`);
|
|
322
|
+
return '';
|
|
323
|
+
}
|
|
324
|
+
const base = this.o.base;
|
|
325
|
+
const ev = { ...base, url_path: base.url_path.startsWith('/') ? base.url_path.slice(0, 512) : '',
|
|
326
|
+
event_id: uuidv7(this.o.now()), name, step_id: stepId, props: safeProps(props), variants: cleanVariants(variants),
|
|
327
|
+
ts_client: this.o.now(), session_id: this.sessionId(), user_id: this.user(), ...cleanAttribution(this.o.attribution) };
|
|
328
|
+
const q = this.queue();
|
|
329
|
+
q.push(ev);
|
|
330
|
+
this.save(q);
|
|
331
|
+
if (q.length >= this.o.maxBatch)
|
|
332
|
+
this.inflight = this.inflight.then(() => this.flush());
|
|
333
|
+
return ev.event_id;
|
|
334
|
+
}
|
|
335
|
+
// Sends what waits now (in the background, after any send already on its way).
|
|
336
|
+
flushSoon() { this.inflight = this.inflight.then(() => this.flush()); }
|
|
337
|
+
// The head of the queue that fits one request: at most 100 events and 48 KB serialized (always at least one event).
|
|
338
|
+
takeBatch(q, key) {
|
|
339
|
+
let size = enc.encode(JSON.stringify({ key, events: [], sent_at: this.o.now() })).length;
|
|
340
|
+
let n = 0;
|
|
341
|
+
for (; n < Math.min(q.length, MAX_BATCH); n++) {
|
|
342
|
+
size += enc.encode(JSON.stringify(unmark(q[n]))).length + 1;
|
|
343
|
+
if (n > 0 && size > MAX_BODY)
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
return q.slice(0, n);
|
|
347
|
+
}
|
|
348
|
+
// Failed sends back off min(5s * 2^(failures-1), 60s) so a down server is not hit by every track(); the unload beacon ignores it.
|
|
349
|
+
async flush(useBeacon = false) {
|
|
350
|
+
const { ingestUrl, key, fallbackUrl } = this.o;
|
|
351
|
+
if (this.stopped())
|
|
352
|
+
return;
|
|
353
|
+
if (!useBeacon && this.o.now() < this.retryAt)
|
|
354
|
+
return;
|
|
355
|
+
const cutoff = this.o.now() - MAX_AGE;
|
|
356
|
+
const all = this.queue();
|
|
357
|
+
const q = all.filter(e => typeof e.ts_client === 'number' && e.ts_client >= cutoff);
|
|
358
|
+
if (q.length !== all.length)
|
|
359
|
+
this.save(q);
|
|
360
|
+
if (!q.length)
|
|
361
|
+
return;
|
|
362
|
+
if (!ingestUrl) {
|
|
363
|
+
this.save([]);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// A beacon gets no answer (the server may have refused it), so beaconed events stay queued, marked _b: the next beacon
|
|
367
|
+
// skips them, the next normal send sends them again and drops them on its answer. A copy within the same day collapses.
|
|
368
|
+
const candidates = useBeacon ? q.filter(e => !e._b) : q;
|
|
369
|
+
if (!candidates.length)
|
|
370
|
+
return;
|
|
371
|
+
const batch = this.takeBatch(candidates, key);
|
|
372
|
+
// sent_at: the device clock at this very send (a retry gets a new one). The server compares it with its own clock and,
|
|
373
|
+
// when they differ by more than 2 s, moves the batch's ts_client by the difference (a phone with a wrong clock).
|
|
374
|
+
const body = JSON.stringify({ key, events: batch.map(unmark), sent_at: this.o.now(), ctx: { ...this.context(), ...(this.o.geo ? { geo: this.o.geo } : {}) } });
|
|
375
|
+
const ids = new Set(batch.map(e => e.event_id));
|
|
376
|
+
const fallback = fallbackUrl && this.fallbackSends < MAX_FALLBACK_SENDS ? fallbackUrl : null;
|
|
377
|
+
if (useBeacon) {
|
|
378
|
+
// A beacon the browser refuses (queue full, too big) is tried on the other address; neither gives an answer. Degraded:
|
|
379
|
+
// the fallback first.
|
|
380
|
+
const order = this.isDegraded ? [fallback, ingestUrl] : [ingestUrl, fallback];
|
|
381
|
+
for (const u of order) {
|
|
382
|
+
if (!u || !this.o.beacon(u, body))
|
|
383
|
+
continue;
|
|
384
|
+
if (u === fallback)
|
|
385
|
+
this.fallbackSends++;
|
|
386
|
+
this.save(this.queue().map(e => ids.has(e.event_id) ? { ...e, _b: 1 } : e));
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// ingest first; after no answer (or none within 8 s), a 5xx or a 429 the same batch (same sent_at) goes to the fallback
|
|
392
|
+
// at once, and its 202 settles it too and makes the tracker degraded. Degraded, ingest is only probed once a minute, and
|
|
393
|
+
// between probes batches go straight to the fallback every 30 s or when maxBatch events wait. Otherwise the batch stays
|
|
394
|
+
// and the backoff grows.
|
|
395
|
+
const now = this.o.now();
|
|
396
|
+
let probe = true;
|
|
397
|
+
if (this.isDegraded) {
|
|
398
|
+
probe = now >= this.nextProbe;
|
|
399
|
+
if (!probe && (!fallback || (q.length < this.o.maxBatch && now < this.lastFallback + DEGRADED_EVERY)))
|
|
400
|
+
return;
|
|
401
|
+
if (probe)
|
|
402
|
+
this.nextProbe = now + PROBE_EVERY;
|
|
403
|
+
}
|
|
404
|
+
const status = probe ? await this.post(ingestUrl, body, true) : -1;
|
|
405
|
+
// Any answer that is not an outage means ingest is up (a 4xx is about the batch, not the server): back to normal.
|
|
406
|
+
if (status > 0 && !OUTAGE(status)) {
|
|
407
|
+
if (this.isDegraded || this.sharedDegradedUntil())
|
|
408
|
+
this.shareDegraded(null);
|
|
409
|
+
this.isDegraded = false;
|
|
410
|
+
}
|
|
411
|
+
let done = FINAL.has(status);
|
|
412
|
+
// stopTracking() may have run while ingest was tried: then the batch goes nowhere else.
|
|
413
|
+
if (!done && (status === -1 || OUTAGE(status)) && fallback && !this.stopped()) {
|
|
414
|
+
this.fallbackSends++;
|
|
415
|
+
this.lastFallback = this.o.now();
|
|
416
|
+
if (await this.post(fallback, body, false) === 202) {
|
|
417
|
+
done = true;
|
|
418
|
+
if (!this.isDegraded) {
|
|
419
|
+
this.isDegraded = true;
|
|
420
|
+
this.nextProbe = this.o.now() + PROBE_EVERY;
|
|
421
|
+
}
|
|
422
|
+
this.shareDegraded(this.nextProbe);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (done)
|
|
426
|
+
this.failures = 0;
|
|
427
|
+
else
|
|
428
|
+
this.retryAt = this.o.now() + Math.min(BACKOFF_BASE * 2 ** this.failures++, BACKOFF_MAX);
|
|
429
|
+
if (done)
|
|
430
|
+
this.save(this.queue().filter(e => !ids.has(e.event_id)));
|
|
431
|
+
}
|
|
432
|
+
idle() { return this.inflight; }
|
|
433
|
+
start() {
|
|
434
|
+
this.stop();
|
|
435
|
+
this.timer = setInterval(() => { this.inflight = this.inflight.then(() => this.flush()); }, this.o.intervalMs);
|
|
436
|
+
addEventListener('pagehide', this.onHide);
|
|
437
|
+
document.addEventListener('visibilitychange', this.onVisibility);
|
|
438
|
+
}
|
|
439
|
+
stop() {
|
|
440
|
+
if (this.timer)
|
|
441
|
+
clearInterval(this.timer);
|
|
442
|
+
this.timer = undefined;
|
|
443
|
+
removeEventListener('pagehide', this.onHide);
|
|
444
|
+
document.removeEventListener('visibilitychange', this.onVisibility);
|
|
445
|
+
}
|
|
446
|
+
}
|
package/dist/uuid.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function uuidv7(now?: number): string;
|
package/dist/uuid.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// UUIDv7: time-ordered, so events sort by creation; used for event_id and session_id.
|
|
2
|
+
export function uuidv7(now = Date.now()) {
|
|
3
|
+
const b = new Uint8Array(16);
|
|
4
|
+
crypto.getRandomValues(b);
|
|
5
|
+
const ms = BigInt(now);
|
|
6
|
+
for (let i = 0; i < 6; i++)
|
|
7
|
+
b[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
|
|
8
|
+
b[6] = (b[6] & 0x0f) | 0x70;
|
|
9
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
10
|
+
const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('');
|
|
11
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
12
|
+
}
|
package/dist/vitals.d.ts
ADDED
package/dist/vitals.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// $perf (kit 0.13.0): how the page loaded and how it felt, once per page load. Sent when the page first goes to the background or
|
|
2
|
+
// is left (visibilitychange to hidden, pagehide), through the tracker's beacon path, because the numbers that matter most
|
|
3
|
+
// (largest paint, layout shift, interaction delay) are only final then. A tab that is killed without ever going hidden sends nothing.
|
|
4
|
+
//
|
|
5
|
+
// Props (all whole numbers, a metric the browser cannot give is left out):
|
|
6
|
+
// ttfb_ms the first byte of the document (navigation responseStart)
|
|
7
|
+
// fcp_ms first contentful paint
|
|
8
|
+
// lcp_ms largest contentful paint, the last candidate before the page was hidden
|
|
9
|
+
// load_ms the load event (loadEventEnd); left out when the page went hidden before it
|
|
10
|
+
// cls cumulative layout shift x 1000 (0.1 is 100), the worst session window of the Core Web Vitals definition
|
|
11
|
+
// inp_ms interaction to next paint, the slow end of the interactions of the visit (Chrome and Safari only)
|
|
12
|
+
// nav navigate, reload, back_forward or prerender
|
|
13
|
+
// Positions on the page, URLs and element names are never read: only the times.
|
|
14
|
+
const NAV = new Set(['navigate', 'reload', 'back_forward', 'prerender']);
|
|
15
|
+
const whole = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v) : undefined);
|
|
16
|
+
export function installVitals(o) {
|
|
17
|
+
if (typeof PerformanceObserver === 'undefined')
|
|
18
|
+
return () => { };
|
|
19
|
+
let fcp;
|
|
20
|
+
let lcp;
|
|
21
|
+
let cls = 0;
|
|
22
|
+
let session = { value: 0, first: 0, last: 0 };
|
|
23
|
+
const interactions = new Map();
|
|
24
|
+
const observers = [];
|
|
25
|
+
const watch = (type, take, extra = {}) => {
|
|
26
|
+
try {
|
|
27
|
+
const po = new PerformanceObserver(list => { try {
|
|
28
|
+
take(list.getEntries());
|
|
29
|
+
}
|
|
30
|
+
catch { /* ignore */ } });
|
|
31
|
+
po.observe({ type, buffered: true, ...extra });
|
|
32
|
+
observers.push({ po, take });
|
|
33
|
+
}
|
|
34
|
+
catch { /* the browser does not know this entry type */ }
|
|
35
|
+
};
|
|
36
|
+
watch('paint', es => { for (const e of es)
|
|
37
|
+
if (e.name === 'first-contentful-paint')
|
|
38
|
+
fcp = e.startTime; });
|
|
39
|
+
watch('largest-contentful-paint', es => { const last = es[es.length - 1]; if (last)
|
|
40
|
+
lcp = last.startTime; });
|
|
41
|
+
watch('layout-shift', es => {
|
|
42
|
+
for (const e of es) {
|
|
43
|
+
if (e.hadRecentInput)
|
|
44
|
+
continue;
|
|
45
|
+
if (session.value === 0 || e.startTime - session.last > 1000 || e.startTime - session.first > 5000)
|
|
46
|
+
session = { value: 0, first: e.startTime, last: e.startTime };
|
|
47
|
+
session.value += e.value ?? 0;
|
|
48
|
+
session.last = e.startTime;
|
|
49
|
+
if (session.value > cls)
|
|
50
|
+
cls = session.value;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
watch('event', es => {
|
|
54
|
+
for (const e of es) {
|
|
55
|
+
if (typeof e.interactionId !== 'number' || e.interactionId <= 0)
|
|
56
|
+
continue;
|
|
57
|
+
interactions.set(e.interactionId, Math.max(interactions.get(e.interactionId) ?? 0, e.duration));
|
|
58
|
+
}
|
|
59
|
+
}, { durationThreshold: 40 });
|
|
60
|
+
let done = false;
|
|
61
|
+
const report = () => {
|
|
62
|
+
if (done)
|
|
63
|
+
return;
|
|
64
|
+
done = true;
|
|
65
|
+
off();
|
|
66
|
+
for (const { po, take } of observers) {
|
|
67
|
+
try {
|
|
68
|
+
take(po.takeRecords());
|
|
69
|
+
}
|
|
70
|
+
catch { /* ignore */ }
|
|
71
|
+
po.disconnect();
|
|
72
|
+
}
|
|
73
|
+
const props = {};
|
|
74
|
+
const put = (k, v) => { if (v !== undefined)
|
|
75
|
+
props[k] = v; };
|
|
76
|
+
let nav;
|
|
77
|
+
try {
|
|
78
|
+
nav = performance.getEntriesByType('navigation')[0];
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
put('ttfb_ms', nav && nav.responseStart > 0 ? whole(nav.responseStart) : undefined);
|
|
82
|
+
put('fcp_ms', whole(fcp));
|
|
83
|
+
put('lcp_ms', lcp ? whole(lcp) : undefined);
|
|
84
|
+
put('load_ms', nav && nav.loadEventEnd > 0 ? whole(nav.loadEventEnd) : undefined);
|
|
85
|
+
if (observers.length)
|
|
86
|
+
put('cls', whole(cls * 1000));
|
|
87
|
+
if (interactions.size) {
|
|
88
|
+
const slow = [...interactions.values()].sort((a, b) => b - a);
|
|
89
|
+
put('inp_ms', whole(slow[Math.min(slow.length - 1, Math.floor(slow.length / 50))]));
|
|
90
|
+
}
|
|
91
|
+
if (nav && NAV.has(nav.type))
|
|
92
|
+
props.nav = nav.type;
|
|
93
|
+
if (Object.keys(props).length === 0 || (Object.keys(props).length === 1 && 'nav' in props))
|
|
94
|
+
return;
|
|
95
|
+
o.send(props);
|
|
96
|
+
};
|
|
97
|
+
const onVisibility = () => { if (document.visibilityState === 'hidden')
|
|
98
|
+
report(); };
|
|
99
|
+
const off = () => { removeEventListener('pagehide', report); document.removeEventListener('visibilitychange', onVisibility); };
|
|
100
|
+
addEventListener('pagehide', report);
|
|
101
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
102
|
+
return () => { done = true; off(); for (const { po } of observers)
|
|
103
|
+
po.disconnect(); };
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appright/funnelshift",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.16.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
9
|
+
"./functions": { "types": "./dist/functions.d.ts", "import": "./dist/functions.js" },
|
|
10
|
+
"./prerender": { "types": "./dist/prerender.d.ts", "import": "./dist/prerender.js" },
|
|
11
|
+
"./blocks.css": "./src/blocks/blocks.css",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"bin": { "funnelshift": "bin/funnel-kit.js", "funnel-kit": "bin/funnel-kit.js" },
|
|
15
|
+
"files": ["dist", "bin", "src/blocks/blocks.css", "templates"],
|
|
16
|
+
"license": "UNLICENSED",
|
|
17
|
+
"repository": { "type": "git", "url": "git+https://github.com/appright-org/funnelshift.git", "directory": "kit" },
|
|
18
|
+
"publishConfig": { "access": "public" },
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"prepare": "tsc -p tsconfig.json"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": { "react": "^19", "react-dom": "^19" },
|
|
26
|
+
"dependencies": { "@babel/parser": "^7", "@vitejs/plugin-react": "^6", "tar": "^7", "vite": "^8" },
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@testing-library/dom": "^10", "@testing-library/react": "^16", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19",
|
|
29
|
+
"jsdom": "^30", "react": "^19", "react-dom": "^19", "typescript": "^7", "vitest": "^5"
|
|
30
|
+
}
|
|
31
|
+
}
|