@faststats/web 0.12.1 → 0.13.1

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 CHANGED
@@ -48,8 +48,8 @@ Consent remains client-local: use `"anonymous"` for cookieless collection,
48
48
 
49
49
  ### Visit duration
50
50
 
51
- `page_leave.properties.time_on_page` is the accumulated time in milliseconds
52
- that the page was visible and its document had focus. It pauses when the tab is
51
+ `properties.time_on_page` on `page_ping` and `page_leave` is the accumulated
52
+ time in milliseconds that the page was visible and its document had focus. It pauses when the tab is
53
53
  hidden, the window loses focus, or the page enters the back-forward cache, and
54
54
  resets on navigation, logout, or session rotation. A monotonic clock keeps
55
55
  system-clock changes from affecting this duration. It also pauses after 60
@@ -60,11 +60,24 @@ the device spent asleep or the tab frozen is not counted.
60
60
  Pageviews wait until the page becomes visible. Pages replaced or closed before
61
61
  being shown do not emit pageviews or page-leave events.
62
62
 
63
- The SDK sends cumulative `page_leave` checkpoints every 15 seconds while active
64
- and when activity pauses or the visit ends. Unchanged checkpoints are suppressed.
65
- Pageviews and their checkpoints share `properties.visit_id`; consumers should use
66
- the maximum `time_on_page` per visit, never sum its checkpoints. The existing
67
- session-duration and bounce-rate queries already use maximum values.
63
+ The SDK sends a cumulative `page_ping` when activity pauses and periodically
64
+ while active: after 15 and 45 seconds, then every 60 seconds. A ping that adds no
65
+ time is suppressed. A visit ends with
66
+ exactly one `page_leave`, on navigation, a terminal `pagehide`, logout, a consent
67
+ change, or session rotation. A page the browser discards without notice never
68
+ sends one, so its last ping holds its duration. A pageview, its pings, and its
69
+ leave share `properties.visit_id`; consumers should use the maximum
70
+ `time_on_page` per visit across both events, never sum them.
71
+
72
+ Before 0.13 the pings were sent as `page_leave` every 15 seconds. The collector
73
+ and dashboards read both shapes; deploy them before releasing this SDK.
74
+
75
+ ### Default context
76
+
77
+ Every event carries `timezone` (IANA name), `language`, and `screen`
78
+ (`1920x1080`, CSS pixels) beside the page context, separate from its properties.
79
+ Custom events and outbound links also carry the `visit_id` of the page view they
80
+ happened during.
68
81
 
69
82
  Each visit retains its original identity, session, and page metadata. A session
70
83
  or identity change starts a new visit, even on the same route. Both persistent
@@ -76,7 +89,7 @@ session, so it is separate from active time on the page. An idle visitor stops
76
89
  extending the session, which then expires after 30 minutes like any other.
77
90
 
78
91
  Delivery is best effort: a sudden process kill or network failure can lose time
79
- since the last delivered checkpoint. The collector must accept successive events
92
+ since the last delivered ping. The collector must accept successive events
80
93
  for the same visitor and URL; deploy the removal of its five-second debounce
81
94
  before releasing this SDK.
82
95
 
@@ -0,0 +1,16 @@
1
+ import { o as getStorageItem, s as setStorageItem, t as createId } from "./session-manager-VM5RZj9H.js";
2
+ //#region src/utils/identifiers.ts
3
+ const key = (siteKey) => `faststats_anon_id_${siteKey}`;
4
+ /** The visitor's persisted anonymous ID, or "" when it cannot be stored. */
5
+ function getAnonymousId(siteKey, cookieless = false) {
6
+ if (cookieless) return "";
7
+ const existing = getStorageItem("localStorage", key(siteKey));
8
+ if (existing) return existing;
9
+ const id = createId();
10
+ return setStorageItem("localStorage", key(siteKey), id) ? id : "";
11
+ }
12
+ function resetAnonymousId(siteKey, cookieless = false) {
13
+ return !cookieless && setStorageItem("localStorage", key(siteKey), null) ? getAnonymousId(siteKey) : "";
14
+ }
15
+ //#endregion
16
+ export { resetAnonymousId as n, getAnonymousId as t };
@@ -0,0 +1,177 @@
1
+ //#region src/extensions.ts
2
+ /** Runs a built-in tracker with the client's settings and page lifecycle. */
3
+ function trackerExtension(name, create, hook) {
4
+ return {
5
+ name,
6
+ setup(context) {
7
+ const { siteKey, baseUrl, debug, trackHash, sdkName, sdkVersion } = context;
8
+ const tracker = create({
9
+ siteKey,
10
+ baseUrl,
11
+ debug,
12
+ trackHash,
13
+ cookieless: context.isCookieless(),
14
+ sdkName,
15
+ sdkVersion
16
+ });
17
+ context.on("pageHide", ({ persisted, terminal }) => tracker.onPageHidden(persisted, terminal));
18
+ context.on("consentChange", ({ cookieless }) => tracker.setCookielessMode(cookieless));
19
+ hook?.(tracker, context);
20
+ tracker.start();
21
+ return ({ discard }) => tracker.stop(discard);
22
+ }
23
+ };
24
+ }
25
+ var ExtensionHost = class {
26
+ context;
27
+ log;
28
+ handlers = /* @__PURE__ */ new Map();
29
+ cleanups = [];
30
+ stopped = false;
31
+ constructor(extensions, context, log) {
32
+ this.context = context;
33
+ this.log = log;
34
+ for (const extension of extensions) this.setup(extension);
35
+ }
36
+ emit(hook, event) {
37
+ if (this.stopped) return;
38
+ for (const handler of this.handlers.get(hook) ?? []) try {
39
+ handler(event)?.catch((error) => this.log(`${hook} failed`, error));
40
+ } catch (error) {
41
+ this.log(`${hook} failed`, error);
42
+ }
43
+ }
44
+ stop(discard = false) {
45
+ if (this.stopped) return;
46
+ this.stopped = true;
47
+ for (const cleanup of this.cleanups.reverse()) try {
48
+ cleanup({ discard });
49
+ } catch (error) {
50
+ this.log("cleanup failed", error);
51
+ }
52
+ this.cleanups.length = 0;
53
+ this.handlers.clear();
54
+ }
55
+ async setup(extension) {
56
+ if (this.stopped) return;
57
+ const subscriptions = [];
58
+ try {
59
+ const cleanup = await extension.setup({
60
+ ...this.context,
61
+ on: (hook, handler) => {
62
+ if (this.stopped) return () => {};
63
+ const handlers = this.handlers.get(hook) ?? /* @__PURE__ */ new Set();
64
+ this.handlers.set(hook, handlers);
65
+ handlers.add(handler);
66
+ const off = () => handlers.delete(handler);
67
+ subscriptions.push(off);
68
+ return off;
69
+ }
70
+ });
71
+ if (this.stopped) cleanup?.({ discard: true });
72
+ else if (cleanup) this.cleanups.push(cleanup);
73
+ } catch (error) {
74
+ for (const off of subscriptions) off();
75
+ this.log(`${extension.name} setup failed`, error);
76
+ }
77
+ }
78
+ };
79
+ //#endregion
80
+ //#region src/utils/api-urls.ts
81
+ function resolveBaseUrl(baseUrl) {
82
+ return baseUrl?.replace(/\/+$/, "") || "https://metrics.faststats.dev";
83
+ }
84
+ const URLS = {
85
+ events: "/v1/web",
86
+ identify: "/v1/identify",
87
+ replay: "/v1/replay",
88
+ vitals: "/v1/vitals"
89
+ };
90
+ function sanitizeUrl(url, includeHash = false) {
91
+ try {
92
+ const parsed = new URL(url, location.href);
93
+ return `${parsed.origin}${parsed.pathname}${includeHash ? parsed.hash : ""}`;
94
+ } catch {
95
+ return url.split(includeHash ? "?" : /[?#]/, 1)[0] ?? "";
96
+ }
97
+ }
98
+ const UTM_KEYS = [
99
+ "utm_source",
100
+ "utm_medium",
101
+ "utm_campaign",
102
+ "utm_term",
103
+ "utm_content"
104
+ ];
105
+ function getReferrer() {
106
+ const raw = document.referrer;
107
+ if (!raw) return null;
108
+ try {
109
+ if (new URL(raw, location.href).origin === location.origin) return null;
110
+ } catch {}
111
+ return sanitizeUrl(raw);
112
+ }
113
+ /** Visitor environment sent with every event; unavailable values are omitted. */
114
+ function getClientContext() {
115
+ let timezone;
116
+ try {
117
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
118
+ } catch {}
119
+ const { width, height } = globalThis.screen ?? {};
120
+ return {
121
+ timezone,
122
+ language: globalThis.navigator?.language,
123
+ screen: width && height ? `${width}x${height}` : void 0
124
+ };
125
+ }
126
+ function getPageContext(includeHash = false) {
127
+ const hash = includeHash ? location.hash : "";
128
+ const ctx = {
129
+ page: `${location.pathname}${hash}`,
130
+ url: sanitizeUrl(location.href, includeHash),
131
+ referrer: getReferrer(),
132
+ title: document.title
133
+ };
134
+ if (!location.search) return ctx;
135
+ const sp = new URLSearchParams(location.search);
136
+ for (const key of UTM_KEYS) {
137
+ const value = sp.get(key);
138
+ if (value) ctx[key] = value;
139
+ }
140
+ return ctx;
141
+ }
142
+ //#endregion
143
+ //#region src/utils/send-data.ts
144
+ async function sendData(options) {
145
+ const { url, data, contentType = "application/json", debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, settleRejected } = options;
146
+ const log = (...details) => {
147
+ if (debug) console.log(debugPrefix, ...details);
148
+ };
149
+ if (useBeacon) try {
150
+ const body = data instanceof Blob ? data : new Blob([data], { type: contentType });
151
+ if (navigator.sendBeacon(url, body)) {
152
+ log("Sent via beacon");
153
+ return true;
154
+ }
155
+ } catch {}
156
+ const post = (keepalive) => fetch(url, {
157
+ method: "POST",
158
+ body: data,
159
+ headers: { "Content-Type": contentType },
160
+ keepalive,
161
+ credentials: "omit"
162
+ });
163
+ try {
164
+ const useKeepalive = keepalive && new Blob([data]).size <= 61440;
165
+ const { ok, status } = await post(useKeepalive).catch((error) => {
166
+ if (!useKeepalive) throw error;
167
+ return post(false);
168
+ });
169
+ log(ok ? "Sent via fetch" : status);
170
+ return ok || !!settleRejected && status < 500 && status !== 429;
171
+ } catch {
172
+ log("Failed to send");
173
+ return false;
174
+ }
175
+ }
176
+ //#endregion
177
+ export { resolveBaseUrl as a, trackerExtension as c, getPageContext as i, URLS as n, sanitizeUrl as o, getClientContext as r, ExtensionHost as s, sendData as t };
@@ -0,0 +1,116 @@
1
+ //#region src/utils/storage.ts
2
+ function getStorageItem(name, key) {
3
+ try {
4
+ return globalThis[name]?.getItem(key) ?? null;
5
+ } catch {
6
+ return null;
7
+ }
8
+ }
9
+ /** Stores `value`, or removes the item for null. False when storage fails. */
10
+ function setStorageItem(name, key, value) {
11
+ try {
12
+ const storage = globalThis[name];
13
+ if (!storage) return false;
14
+ if (value === null) storage.removeItem(key);
15
+ else storage.setItem(key, value);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+ //#endregion
22
+ //#region src/utils/session-manager.ts
23
+ const IDLE_MS = 18e5;
24
+ const MAX_AGE_MS = 864e5;
25
+ const SESSION_KEY = "faststats_session_";
26
+ const WINDOW_KEY = "faststats_window_id_";
27
+ const memory = /* @__PURE__ */ new Map();
28
+ const listeners = /* @__PURE__ */ new Map();
29
+ const NO_STORAGE = {};
30
+ const slot = (siteKey, cookieless) => `${siteKey}:${cookieless ? "memory" : "persistent"}`;
31
+ function storageScope() {
32
+ try {
33
+ return globalThis.localStorage ?? NO_STORAGE;
34
+ } catch {
35
+ return NO_STORAGE;
36
+ }
37
+ }
38
+ function createId() {
39
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
40
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
41
+ }
42
+ function load(siteKey, cookieless) {
43
+ if (!cookieless) try {
44
+ const session = JSON.parse(getStorageItem("localStorage", `${SESSION_KEY}${siteKey}`) ?? "null");
45
+ if (typeof session?.id === "string" && session.id && Number.isFinite(session.start) && Number.isFinite(session.activity)) return session;
46
+ } catch {}
47
+ const entry = memory.get(slot(siteKey, cookieless));
48
+ return entry?.scope === storageScope() ? entry.session : null;
49
+ }
50
+ function save(siteKey, cookieless, session) {
51
+ const json = JSON.stringify(session);
52
+ if (cookieless || !setStorageItem("localStorage", SESSION_KEY + siteKey, json)) memory.set(slot(siteKey, cookieless), {
53
+ scope: storageScope(),
54
+ session
55
+ });
56
+ }
57
+ function toContext(siteKey, cookieless, { id, start }) {
58
+ let windowId = cookieless ? id : getStorageItem("sessionStorage", WINDOW_KEY + siteKey);
59
+ if (!windowId) {
60
+ windowId = createId();
61
+ if (!setStorageItem("sessionStorage", WINDOW_KEY + siteKey, windowId)) windowId = id;
62
+ }
63
+ return {
64
+ sessionId: id,
65
+ windowId,
66
+ sessionStart: start
67
+ };
68
+ }
69
+ function emit(siteKey, cookieless, prev, next) {
70
+ for (const listener of listeners.get(slot(siteKey, cookieless)) ?? []) listener(prev, next);
71
+ }
72
+ function resolveSession(siteKey, cookieless, touch) {
73
+ const now = Date.now();
74
+ const existing = load(siteKey, cookieless);
75
+ const expired = !!existing && (now - existing.activity >= IDLE_MS || now - existing.start >= MAX_AGE_MS);
76
+ const session = existing && !expired ? existing : {
77
+ id: createId(),
78
+ start: now,
79
+ activity: now
80
+ };
81
+ if (touch) session.activity = now;
82
+ if (touch || session !== existing) save(siteKey, cookieless, session);
83
+ const context = toContext(siteKey, cookieless, session);
84
+ if (existing && expired) emit(siteKey, cookieless, toContext(siteKey, cookieless, existing), context);
85
+ return context;
86
+ }
87
+ /** The current session, replacing an expired one without counting activity. */
88
+ function getSessionContext(siteKey, cookieless = false) {
89
+ return resolveSession(siteKey, cookieless, false);
90
+ }
91
+ /** Counts visitor activity in the current session, which it returns. */
92
+ function touchActivity(siteKey, cookieless = false) {
93
+ return resolveSession(siteKey, cookieless, true);
94
+ }
95
+ function resetSession(siteKey, cookieless = false) {
96
+ const prev = getSessionContext(siteKey, cookieless);
97
+ memory.delete(slot(siteKey, cookieless));
98
+ if (!cookieless) {
99
+ setStorageItem("localStorage", SESSION_KEY + siteKey, null);
100
+ setStorageItem("sessionStorage", WINDOW_KEY + siteKey, null);
101
+ }
102
+ const next = getSessionContext(siteKey, cookieless);
103
+ if (prev.sessionId !== next.sessionId) emit(siteKey, cookieless, prev, next);
104
+ return next;
105
+ }
106
+ function onSessionRotated(siteKey, cookieless, listener) {
107
+ const key = slot(siteKey, cookieless);
108
+ const set = listeners.get(key) ?? /* @__PURE__ */ new Set();
109
+ listeners.set(key, set.add(listener));
110
+ return () => {
111
+ set.delete(listener);
112
+ if (!set.size) listeners.delete(key);
113
+ };
114
+ }
115
+ //#endregion
116
+ export { touchActivity as a, resetSession as i, getSessionContext as n, getStorageItem as o, onSessionRotated as r, setStorageItem as s, createId as t };
package/dist/error.d.ts CHANGED
@@ -39,6 +39,8 @@ export default class ErrorTracker {
39
39
  private onError;
40
40
  private onRejection;
41
41
  private enqueue;
42
+ /** Returns true when the error was already queued. */
43
+ private add;
42
44
  private flush;
43
45
  }
44
46
  //#endregion
package/dist/error.js CHANGED
@@ -1,30 +1,14 @@
1
- import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as sendData } from "./chunks/send-data-gBIF6DTb.js";
2
- import { n as getSessionContext } from "./chunks/session-manager-DUpF3mi3.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
1
+ import { a as resolveBaseUrl, c as trackerExtension, i as getPageContext, n as URLS, t as sendData } from "./chunks/send-data-B7P3f5PT.js";
2
+ import { n as getSessionContext } from "./chunks/session-manager-VM5RZj9H.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-3X8XI1pD.js";
4
4
  //#region src/error.ts
5
5
  const SDK_NAME = "@faststats/web";
6
- const SDK_VERSION = "0.12.1";
6
+ const SDK_VERSION = "0.13.1";
7
7
  function errorTracking(options = {}) {
8
- return {
9
- name: "error-tracking",
10
- setup(context) {
11
- const tracker = new ErrorTracker({
12
- ...options,
13
- siteKey: context.siteKey,
14
- baseUrl: context.baseUrl,
15
- debug: context.debug,
16
- trackHash: context.trackHash,
17
- cookieless: context.isCookieless(),
18
- sdkName: context.sdkName,
19
- sdkVersion: context.sdkVersion
20
- });
21
- context.on("pageHide", ({ persisted }) => tracker.onPageHidden(persisted));
22
- context.on("error", ({ error }) => tracker.captureError(error));
23
- context.on("consentChange", ({ cookieless }) => tracker.setCookielessMode(cookieless));
24
- tracker.start();
25
- return ({ discard }) => tracker.stop(discard);
26
- }
27
- };
8
+ return trackerExtension("error-tracking", (context) => new ErrorTracker({
9
+ ...options,
10
+ ...context
11
+ }), (tracker, context) => context.on("error", ({ error }) => tracker.captureError(error)));
28
12
  }
29
13
  const EXTENSION_URL = /(?:^|\()(chrome|moz|ms-browser|safari-web)-extension:\/\//;
30
14
  function toEntry(kind, source, handled, fallback) {
@@ -103,23 +87,34 @@ var ErrorTracker = class {
103
87
  const buildId = globalThis.__SOURCEMAPS_BUILD__?.buildId;
104
88
  const userId = getAnonymousId(this.options.siteKey, this.cookieless);
105
89
  const ctx = getSessionContext(this.options.siteKey, this.cookieless);
106
- this.queue.push({
90
+ const context = {
91
+ token: this.options.siteKey,
92
+ ...userId ? { userId } : {},
93
+ sessionId: ctx.sessionId,
94
+ windowId: ctx.windowId,
95
+ ...typeof buildId === "string" && buildId.trim() ? { buildId } : {},
96
+ sdkName: this.options.sdkName ?? SDK_NAME,
97
+ sdkVersion: this.options.sdkVersion ?? SDK_VERSION,
98
+ event: "error",
99
+ ...getPageContext(this.options.trackHash ?? false),
100
+ properties: {}
101
+ };
102
+ const key = JSON.stringify([context, error]);
103
+ if (this.add({
104
+ key,
105
+ context,
107
106
  error,
108
- context: {
109
- token: this.options.siteKey,
110
- ...userId ? { userId } : {},
111
- sessionId: ctx.sessionId,
112
- windowId: ctx.windowId,
113
- ...typeof buildId === "string" && buildId.trim() ? { buildId } : {},
114
- sdkName: this.options.sdkName ?? SDK_NAME,
115
- sdkVersion: this.options.sdkVersion ?? SDK_VERSION,
116
- event: "error",
117
- ...getPageContext(this.options.trackHash ?? false),
118
- properties: {}
119
- }
120
- });
107
+ count: 1
108
+ })) return;
121
109
  if (this.queue.length >= this.maxQueueSize) this.flush();
122
110
  }
111
+ /** Returns true when the error was already queued. */
112
+ add(item) {
113
+ const queued = this.queue.find(({ key }) => key === item.key);
114
+ if (queued) queued.count += item.count;
115
+ else this.queue.push(item);
116
+ return !!queued;
117
+ }
123
118
  async flush() {
124
119
  if (this.flushing || !this.queue.length) return;
125
120
  this.flushing = true;
@@ -129,42 +124,35 @@ var ErrorTracker = class {
129
124
  const groups = /* @__PURE__ */ new Map();
130
125
  for (const item of batch) {
131
126
  const key = JSON.stringify(item.context);
132
- const group = groups.get(key) ?? {
133
- context: item.context,
134
- items: []
135
- };
136
- group.items.push(item);
137
- groups.set(key, group);
127
+ const group = groups.get(key);
128
+ if (group) group.push(item);
129
+ else groups.set(key, [item]);
138
130
  }
139
131
  let failed = [];
140
132
  try {
141
- failed = (await Promise.all([...groups.values()].map(async (group) => {
142
- const errors = /* @__PURE__ */ new Map();
143
- for (const { error } of group.items) {
144
- const key = JSON.stringify(error);
145
- const existing = errors.get(key);
146
- if (existing) existing.count++;
147
- else errors.set(key, {
148
- ...error,
149
- count: 1
150
- });
151
- }
133
+ failed = (await Promise.all([...groups.values()].map(async (items) => {
152
134
  return await sendData({
153
135
  url: this.endpoint,
154
136
  data: JSON.stringify({
155
- ...group.context,
156
- errors: [...errors.values()]
137
+ ...items[0]?.context,
138
+ errors: items.map(({ error, count }) => ({
139
+ ...error,
140
+ count
141
+ }))
157
142
  }),
158
143
  debug: this.options.debug,
159
144
  debugPrefix: "[ErrorTracker]",
160
145
  settleRejected: true
161
- }) ? [] : group.items;
146
+ }) ? [] : items;
162
147
  }))).flat();
163
148
  } finally {
164
149
  this.flushing = false;
165
150
  }
166
151
  if (generation !== this.generation) return;
167
- this.queue = [...failed, ...this.queue].slice(-this.maxQueueSize);
152
+ const captured = this.queue;
153
+ this.queue = [...failed];
154
+ for (const item of captured) this.add(item);
155
+ this.queue = this.queue.slice(-this.maxQueueSize);
168
156
  if (!this.started && !failed.length) this.flush();
169
157
  }
170
158
  };
package/dist/flags.js CHANGED
@@ -1,5 +1,5 @@
1
- import { n as getSessionContext } from "./chunks/session-manager-DUpF3mi3.js";
2
- import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
1
+ import { n as getSessionContext } from "./chunks/session-manager-VM5RZj9H.js";
2
+ import { t as getAnonymousId } from "./chunks/identifiers-3X8XI1pD.js";
3
3
  //#region src/flags.ts
4
4
  const DEFAULT_ENDPOINT = "https://flags.faststats.dev";
5
5
  const DEFAULT_TIMEOUT_MS = 3e3;
package/dist/index.d.ts CHANGED
@@ -66,6 +66,7 @@ export declare class WebAnalytics {
66
66
  private readonly onPageHide;
67
67
  private readonly onPageShow;
68
68
  private readonly onResume;
69
+ private readonly onBlur;
69
70
  private readonly onActivity;
70
71
  private isIdle;
71
72
  private leavePage;