@arthurreira/analytics 0.17.0 → 0.19.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.
@@ -38,10 +38,39 @@ var AfAnalytics = (() => {
38
38
  // src/vanilla.ts
39
39
  var vanilla_exports = {};
40
40
  __export(vanilla_exports, {
41
- init: () => init
41
+ consent: () => consent,
42
+ init: () => init,
43
+ isOptedOut: () => isOptedOut,
44
+ optIn: () => optIn,
45
+ optOut: () => optOut
42
46
  });
47
+
48
+ // src/lib/consent.ts
49
+ var CONSENT_KEY = "af_analytics_opted_out";
50
+ function optOut() {
51
+ if (typeof localStorage === "undefined") return;
52
+ localStorage.setItem(CONSENT_KEY, "1");
53
+ }
54
+ function optIn() {
55
+ if (typeof localStorage === "undefined") return;
56
+ localStorage.removeItem(CONSENT_KEY);
57
+ }
58
+ function isOptedOut() {
59
+ if (typeof localStorage !== "undefined" && localStorage.getItem(CONSENT_KEY) === "1") return true;
60
+ if (typeof navigator !== "undefined" && navigator.doNotTrack === "1") return true;
61
+ return false;
62
+ }
63
+ function consent(enabled) {
64
+ if (enabled) optIn();
65
+ else optOut();
66
+ }
67
+
68
+ // src/vanilla.ts
43
69
  var _currentScript = document.currentScript;
44
70
  var SESSION_EXPIRY_MS = 30 * 60 * 1e3;
71
+ var IDLE_TOUCH_INTERVAL_MS = 5 * 60 * 1e3;
72
+ var BC_CHANNEL = "af_analytics_session";
73
+ var BC_ADOPT_TIMEOUT_MS = 50;
45
74
  function readConfig() {
46
75
  var _a, _b, _c, _d;
47
76
  const script = _currentScript != null ? _currentScript : document.querySelector("script[data-api-key]");
@@ -94,6 +123,7 @@ var AfAnalytics = (() => {
94
123
  }
95
124
  }
96
125
  function send(apiUrl, apiKey, payload) {
126
+ if (isOptedOut()) return;
97
127
  fetch(`${apiUrl}/events`, {
98
128
  method: "POST",
99
129
  headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
@@ -110,6 +140,49 @@ var AfAnalytics = (() => {
110
140
  page_url: `${window.location.origin}${window.location.pathname}`
111
141
  };
112
142
  }
143
+ function requestSessionFromPeer() {
144
+ if (typeof BroadcastChannel === "undefined") return Promise.resolve(null);
145
+ return new Promise((resolve) => {
146
+ let channel;
147
+ try {
148
+ channel = new BroadcastChannel(BC_CHANNEL);
149
+ } catch (e) {
150
+ resolve(null);
151
+ return;
152
+ }
153
+ const timer = setTimeout(() => {
154
+ channel.close();
155
+ resolve(null);
156
+ }, BC_ADOPT_TIMEOUT_MS);
157
+ channel.onmessage = (e) => {
158
+ var _a;
159
+ if (((_a = e.data) == null ? void 0 : _a.type) === "session_adopt" && typeof e.data.session_id === "string") {
160
+ clearTimeout(timer);
161
+ channel.close();
162
+ resolve(e.data.session_id);
163
+ }
164
+ };
165
+ channel.postMessage({ type: "session_claim" });
166
+ });
167
+ }
168
+ function openSessionResponder(sessionId) {
169
+ if (typeof BroadcastChannel === "undefined") return () => {
170
+ };
171
+ let channel;
172
+ try {
173
+ channel = new BroadcastChannel(BC_CHANNEL);
174
+ } catch (e) {
175
+ return () => {
176
+ };
177
+ }
178
+ channel.onmessage = (e) => {
179
+ var _a;
180
+ if (((_a = e.data) == null ? void 0 : _a.type) === "session_claim") {
181
+ channel.postMessage({ type: "session_adopt", session_id: sessionId });
182
+ }
183
+ };
184
+ return () => channel.close();
185
+ }
113
186
  async function createRemoteSession(apiUrl, apiKey, visitorId) {
114
187
  var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m;
115
188
  const nav = navigator;
@@ -151,9 +224,17 @@ var AfAnalytics = (() => {
151
224
  touchSession();
152
225
  return stored;
153
226
  }
227
+ const adopted = await requestSessionFromPeer();
228
+ if (adopted) {
229
+ localStorage.setItem("af_session_id", adopted);
230
+ localStorage.setItem("af_session_last_activity", String(Date.now()));
231
+ return adopted;
232
+ }
154
233
  const id = await createRemoteSession(apiUrl, apiKey, getVisitorId());
155
- localStorage.setItem("af_session_id", id);
156
- localStorage.setItem("af_session_last_activity", String(Date.now()));
234
+ if (id) {
235
+ localStorage.setItem("af_session_id", id);
236
+ localStorage.setItem("af_session_last_activity", String(Date.now()));
237
+ }
157
238
  return id;
158
239
  }
159
240
  function trackPageview(apiUrl, apiKey, sessionId) {
@@ -181,6 +262,7 @@ var AfAnalytics = (() => {
181
262
  send(apiUrl, apiKey, __spreadValues(__spreadValues({}, baseFields(sessionId, "js_exception")), fields));
182
263
  }
183
264
  function init() {
265
+ if (isOptedOut()) return;
184
266
  const config = readConfig();
185
267
  if (!config) return;
186
268
  const { apiKey, apiUrl } = config;
@@ -188,6 +270,9 @@ var AfAnalytics = (() => {
188
270
  const pending = [];
189
271
  let sessionEnded = false;
190
272
  let lastScrollDepth = 0;
273
+ let closeResponder = () => {
274
+ };
275
+ let idleTimer = null;
191
276
  function enqueue(fn) {
192
277
  if (sessionId) {
193
278
  touchSession();
@@ -196,9 +281,43 @@ var AfAnalytics = (() => {
196
281
  pending.push(fn);
197
282
  }
198
283
  }
199
- getOrCreateSession(apiUrl, apiKey).then((id) => {
284
+ function startSession(id) {
200
285
  sessionId = id;
286
+ closeResponder = openSessionResponder(id);
287
+ idleTimer = setInterval(touchSession, IDLE_TOUCH_INTERVAL_MS);
201
288
  pending.splice(0).forEach((fn) => fn(id));
289
+ }
290
+ function endSession() {
291
+ if (!sessionId || sessionEnded) return;
292
+ sessionEnded = true;
293
+ if (idleTimer !== null) {
294
+ clearInterval(idleTimer);
295
+ idleTimer = null;
296
+ }
297
+ closeResponder();
298
+ navigator.sendBeacon(`${apiUrl}/sessions/${sessionId}/end`);
299
+ clearSession();
300
+ }
301
+ function restartSession() {
302
+ sessionEnded = false;
303
+ sessionId = null;
304
+ if (idleTimer !== null) {
305
+ clearInterval(idleTimer);
306
+ idleTimer = null;
307
+ }
308
+ closeResponder();
309
+ closeResponder = () => {
310
+ };
311
+ clearSession();
312
+ getOrCreateSession(apiUrl, apiKey).then((id) => {
313
+ if (!id) return;
314
+ startSession(id);
315
+ trackPageview(apiUrl, apiKey, id);
316
+ }).catch(() => {
317
+ });
318
+ }
319
+ getOrCreateSession(apiUrl, apiKey).then((id) => {
320
+ if (id) startSession(id);
202
321
  }).catch(() => {
203
322
  });
204
323
  enqueue((id) => trackPageview(apiUrl, apiKey, id));
@@ -263,12 +382,11 @@ var AfAnalytics = (() => {
263
382
  });
264
383
  });
265
384
  });
266
- const endSession = () => {
267
- if (!sessionId || sessionEnded) return;
268
- sessionEnded = true;
269
- navigator.sendBeacon(`${apiUrl}/sessions/${sessionId}/end`);
270
- clearSession();
271
- };
385
+ window.addEventListener("storage", (e) => {
386
+ if (e.key === "af_session_id" && e.newValue === null && e.oldValue !== null) {
387
+ restartSession();
388
+ }
389
+ });
272
390
  let visHideTimer = null;
273
391
  document.addEventListener("visibilitychange", () => {
274
392
  if (document.visibilityState === "hidden") {
@@ -280,7 +398,17 @@ var AfAnalytics = (() => {
280
398
  }
281
399
  }
282
400
  });
283
- window.addEventListener("pagehide", endSession);
401
+ window.addEventListener("pagehide", (e) => {
402
+ if (!e.persisted) endSession();
403
+ });
404
+ window.addEventListener("pageshow", (e) => {
405
+ if (!e.persisted) return;
406
+ if (sessionEnded || !sessionId) {
407
+ restartSession();
408
+ } else {
409
+ trackPageview(apiUrl, apiKey, sessionId);
410
+ }
411
+ });
284
412
  window.addEventListener("beforeunload", endSession);
285
413
  }
286
414
  if (document.readyState === "loading") {
package/dist/client.js CHANGED
@@ -8,6 +8,14 @@ import { usePathname } from "next/navigation";
8
8
  // src/hooks/useAnalytics.ts
9
9
  import { useEffect, useRef } from "react";
10
10
 
11
+ // src/lib/consent.ts
12
+ var CONSENT_KEY = "af_analytics_opted_out";
13
+ function isOptedOut() {
14
+ if (typeof localStorage !== "undefined" && localStorage.getItem(CONSENT_KEY) === "1") return true;
15
+ if (typeof navigator !== "undefined" && navigator.doNotTrack === "1") return true;
16
+ return false;
17
+ }
18
+
11
19
  // src/lib/api.ts
12
20
  var BASE_FIELDS = (sessionId, eventType, path) => ({
13
21
  session_id: sessionId,
@@ -28,6 +36,7 @@ function getGpu() {
28
36
  }
29
37
  }
30
38
  async function createSession(apiUrl, apiKey, visitorId) {
39
+ if (isOptedOut()) return null;
31
40
  const sessionData = { visitor_id: visitorId };
32
41
  if (typeof window !== "undefined") {
33
42
  const nav = navigator;
@@ -77,6 +86,7 @@ async function createSession(apiUrl, apiKey, visitorId) {
77
86
  }
78
87
  }
79
88
  async function sendEvent(apiUrl, apiKey, payload) {
89
+ if (isOptedOut()) return;
80
90
  await fetch(`${apiUrl}/events`, {
81
91
  method: "POST",
82
92
  headers: {
@@ -216,7 +226,51 @@ function connectPresence(apiKey, sessionId, wsUrl = DEFAULT_WS_URL) {
216
226
 
217
227
  // src/hooks/useAnalytics.ts
218
228
  var SESSION_EXPIRY_MINUTES = 30;
229
+ var IDLE_TOUCH_INTERVAL_MS = 5 * 60 * 1e3;
230
+ var BC_CHANNEL = "af_analytics_session";
231
+ var BC_ADOPT_TIMEOUT_MS = 50;
219
232
  var _sessionFlight = null;
233
+ function requestSessionFromPeer() {
234
+ if (typeof BroadcastChannel === "undefined") return Promise.resolve(null);
235
+ return new Promise((resolve) => {
236
+ let channel;
237
+ try {
238
+ channel = new BroadcastChannel(BC_CHANNEL);
239
+ } catch {
240
+ resolve(null);
241
+ return;
242
+ }
243
+ const timer = setTimeout(() => {
244
+ channel.close();
245
+ resolve(null);
246
+ }, BC_ADOPT_TIMEOUT_MS);
247
+ channel.onmessage = (e) => {
248
+ if (e.data?.type === "session_adopt" && typeof e.data.session_id === "string") {
249
+ clearTimeout(timer);
250
+ channel.close();
251
+ resolve(e.data.session_id);
252
+ }
253
+ };
254
+ channel.postMessage({ type: "session_claim" });
255
+ });
256
+ }
257
+ function openSessionResponder(sessionId) {
258
+ if (typeof BroadcastChannel === "undefined") return () => {
259
+ };
260
+ let channel;
261
+ try {
262
+ channel = new BroadcastChannel(BC_CHANNEL);
263
+ } catch {
264
+ return () => {
265
+ };
266
+ }
267
+ channel.onmessage = (e) => {
268
+ if (e.data?.type === "session_claim") {
269
+ channel.postMessage({ type: "session_adopt", session_id: sessionId });
270
+ }
271
+ };
272
+ return () => channel.close();
273
+ }
220
274
  async function getOrCreateSession(apiUrl, apiKey, visitorId) {
221
275
  const storedSessionId = localStorage.getItem("af_session_id");
222
276
  const lastActivity = localStorage.getItem("af_session_last_activity");
@@ -231,11 +285,20 @@ async function getOrCreateSession(apiUrl, apiKey, visitorId) {
231
285
  localStorage.removeItem("af_session_last_activity");
232
286
  }
233
287
  if (_sessionFlight) return _sessionFlight;
234
- _sessionFlight = createSession(apiUrl, apiKey, visitorId).then((id) => {
288
+ _sessionFlight = (async () => {
289
+ const adopted = await requestSessionFromPeer();
290
+ if (adopted) {
291
+ localStorage.setItem("af_session_id", adopted);
292
+ localStorage.setItem("af_session_last_activity", String(Date.now()));
293
+ return adopted;
294
+ }
295
+ const id = await createSession(apiUrl, apiKey, visitorId);
235
296
  if (id) {
236
297
  localStorage.setItem("af_session_id", id);
237
298
  localStorage.setItem("af_session_last_activity", String(Date.now()));
238
299
  }
300
+ return id;
301
+ })().then((id) => {
239
302
  _sessionFlight = null;
240
303
  return id;
241
304
  }).catch((err) => {
@@ -249,6 +312,13 @@ function useAnalytics(apiUrl, apiKey, currentPath, wsUrl) {
249
312
  const pendingEvents = useRef([]);
250
313
  const hasSentEnd = useRef(false);
251
314
  const presenceRef = useRef(null);
315
+ const closeResponderRef = useRef(() => {
316
+ });
317
+ const idleTimerRef = useRef(null);
318
+ const currentPathRef = useRef(currentPath);
319
+ useEffect(() => {
320
+ currentPathRef.current = currentPath;
321
+ }, [currentPath]);
252
322
  useEffect(() => {
253
323
  if (typeof window === "undefined") return;
254
324
  const visitor_id = localStorage.getItem("af_analytics_visitor_id") || crypto.randomUUID();
@@ -257,6 +327,12 @@ function useAnalytics(apiUrl, apiKey, currentPath, wsUrl) {
257
327
  getOrCreateSession(apiUrl, apiKey, visitor_id).then((id) => {
258
328
  if (cancelled || !id) return;
259
329
  sessionId.current = id;
330
+ closeResponderRef.current = openSessionResponder(id);
331
+ idleTimerRef.current = setInterval(() => {
332
+ if (!hasSentEnd.current) {
333
+ localStorage.setItem("af_session_last_activity", String(Date.now()));
334
+ }
335
+ }, IDLE_TOUCH_INTERVAL_MS);
260
336
  if (wsUrl) {
261
337
  presenceRef.current = connectPresence(apiKey, id, wsUrl);
262
338
  }
@@ -272,11 +348,61 @@ function useAnalytics(apiUrl, apiKey, currentPath, wsUrl) {
272
348
  const sendEnd = () => {
273
349
  if (!sessionId.current || hasSentEnd.current) return;
274
350
  hasSentEnd.current = true;
351
+ if (idleTimerRef.current !== null) {
352
+ clearInterval(idleTimerRef.current);
353
+ idleTimerRef.current = null;
354
+ }
355
+ closeResponderRef.current();
275
356
  presenceRef.current?.disconnect();
357
+ presenceRef.current = null;
276
358
  navigator.sendBeacon(`${apiUrl}/sessions/${sessionId.current}/end`);
277
359
  localStorage.removeItem("af_session_id");
278
360
  localStorage.removeItem("af_session_last_activity");
279
361
  };
362
+ const restartSession = () => {
363
+ sessionId.current = null;
364
+ hasSentEnd.current = false;
365
+ if (idleTimerRef.current !== null) {
366
+ clearInterval(idleTimerRef.current);
367
+ idleTimerRef.current = null;
368
+ }
369
+ closeResponderRef.current();
370
+ closeResponderRef.current = () => {
371
+ };
372
+ presenceRef.current?.disconnect();
373
+ presenceRef.current = null;
374
+ const visitor_id = localStorage.getItem("af_analytics_visitor_id") || crypto.randomUUID();
375
+ getOrCreateSession(apiUrl, apiKey, visitor_id).then((id) => {
376
+ if (!id) return;
377
+ sessionId.current = id;
378
+ closeResponderRef.current = openSessionResponder(id);
379
+ idleTimerRef.current = setInterval(() => {
380
+ if (!hasSentEnd.current) {
381
+ localStorage.setItem("af_session_last_activity", String(Date.now()));
382
+ }
383
+ }, IDLE_TOUCH_INTERVAL_MS);
384
+ if (wsUrl) {
385
+ presenceRef.current = connectPresence(apiKey, id, wsUrl);
386
+ }
387
+ trackPageview(apiUrl, apiKey, id, currentPathRef.current);
388
+ });
389
+ };
390
+ const handleStorage = (e) => {
391
+ if (e.key === "af_session_id" && e.newValue === null && e.oldValue !== null) {
392
+ restartSession();
393
+ }
394
+ };
395
+ const handlePageHide = (e) => {
396
+ if (!e.persisted) sendEnd();
397
+ };
398
+ const handlePageShow = (e) => {
399
+ if (!e.persisted) return;
400
+ if (hasSentEnd.current || !sessionId.current) {
401
+ restartSession();
402
+ } else {
403
+ trackPageview(apiUrl, apiKey, sessionId.current, currentPathRef.current);
404
+ }
405
+ };
280
406
  let visHideTimer = null;
281
407
  const handleVisibility = () => {
282
408
  if (document.visibilityState === "hidden") {
@@ -288,13 +414,17 @@ function useAnalytics(apiUrl, apiKey, currentPath, wsUrl) {
288
414
  }
289
415
  }
290
416
  };
417
+ window.addEventListener("storage", handleStorage);
291
418
  document.addEventListener("visibilitychange", handleVisibility);
292
- window.addEventListener("pagehide", sendEnd);
419
+ window.addEventListener("pagehide", handlePageHide);
420
+ window.addEventListener("pageshow", handlePageShow);
293
421
  window.addEventListener("beforeunload", sendEnd);
294
422
  return () => {
295
423
  if (visHideTimer !== null) clearTimeout(visHideTimer);
424
+ window.removeEventListener("storage", handleStorage);
296
425
  document.removeEventListener("visibilitychange", handleVisibility);
297
- window.removeEventListener("pagehide", sendEnd);
426
+ window.removeEventListener("pagehide", handlePageHide);
427
+ window.removeEventListener("pageshow", handlePageShow);
298
428
  window.removeEventListener("beforeunload", sendEnd);
299
429
  };
300
430
  }, [apiUrl]);
package/dist/index.d.ts CHANGED
@@ -1,3 +1,12 @@
1
+ declare function optOut(): void;
2
+ declare function optIn(): void;
3
+ /**
4
+ * Returns true when the user has opted out via localStorage flag OR
5
+ * the browser-level DNT header is set to "1".
6
+ */
7
+ declare function isOptedOut(): boolean;
8
+ declare function consent(enabled: boolean): void;
9
+
1
10
  declare function createSession(apiUrl: string, apiKey: string, visitorId?: string): Promise<string | null>;
2
11
  declare function trackPageview(apiUrl: string, apiKey: string, sessionId: string, path: string): Promise<void>;
3
12
  declare function trackClick(apiUrl: string, apiKey: string, sessionId: string, path: string, e: MouseEvent, element: HTMLElement): Promise<void>;
@@ -19,4 +28,4 @@ interface PresenceConnection {
19
28
  }
20
29
  declare function connectPresence(apiKey: string, sessionId: string, wsUrl?: string): PresenceConnection;
21
30
 
22
- export { DEFAULT_WS_URL, type ExceptionFields, type PresenceConnection, connectPresence, createSession, trackCTA, trackClick, trackCopy, trackError, trackException, trackPageview, trackScroll, trackSearch };
31
+ export { DEFAULT_WS_URL, type ExceptionFields, type PresenceConnection, connectPresence, consent, createSession, isOptedOut, optIn, optOut, trackCTA, trackClick, trackCopy, trackError, trackException, trackPageview, trackScroll, trackSearch };
package/dist/index.js CHANGED
@@ -1,3 +1,23 @@
1
+ // src/lib/consent.ts
2
+ var CONSENT_KEY = "af_analytics_opted_out";
3
+ function optOut() {
4
+ if (typeof localStorage === "undefined") return;
5
+ localStorage.setItem(CONSENT_KEY, "1");
6
+ }
7
+ function optIn() {
8
+ if (typeof localStorage === "undefined") return;
9
+ localStorage.removeItem(CONSENT_KEY);
10
+ }
11
+ function isOptedOut() {
12
+ if (typeof localStorage !== "undefined" && localStorage.getItem(CONSENT_KEY) === "1") return true;
13
+ if (typeof navigator !== "undefined" && navigator.doNotTrack === "1") return true;
14
+ return false;
15
+ }
16
+ function consent(enabled) {
17
+ if (enabled) optIn();
18
+ else optOut();
19
+ }
20
+
1
21
  // src/lib/api.ts
2
22
  var BASE_FIELDS = (sessionId, eventType, path) => ({
3
23
  session_id: sessionId,
@@ -18,6 +38,7 @@ function getGpu() {
18
38
  }
19
39
  }
20
40
  async function createSession(apiUrl, apiKey, visitorId) {
41
+ if (isOptedOut()) return null;
21
42
  const sessionData = { visitor_id: visitorId };
22
43
  if (typeof window !== "undefined") {
23
44
  const nav = navigator;
@@ -67,6 +88,7 @@ async function createSession(apiUrl, apiKey, visitorId) {
67
88
  }
68
89
  }
69
90
  async function sendEvent(apiUrl, apiKey, payload) {
91
+ if (isOptedOut()) return;
70
92
  await fetch(`${apiUrl}/events`, {
71
93
  method: "POST",
72
94
  headers: {
@@ -206,7 +228,11 @@ function connectPresence(apiKey, sessionId, wsUrl = DEFAULT_WS_URL) {
206
228
  export {
207
229
  DEFAULT_WS_URL,
208
230
  connectPresence,
231
+ consent,
209
232
  createSession,
233
+ isOptedOut,
234
+ optIn,
235
+ optOut,
210
236
  trackCTA,
211
237
  trackClick,
212
238
  trackCopy,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthurreira/analytics",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsup",