@arthurreira/analytics 0.21.0 → 0.23.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.
@@ -133,6 +133,16 @@ var AfAnalytics = (() => {
133
133
  }).catch(() => {
134
134
  });
135
135
  }
136
+ function sendHeartbeat(apiUrl, apiKey, sessionId) {
137
+ if (isOptedOut()) return;
138
+ fetch(`${apiUrl}/sessions/${sessionId}/heartbeat`, {
139
+ method: "PATCH",
140
+ headers: { "Content-Type": "application/json" },
141
+ body: JSON.stringify({ api_key: apiKey }),
142
+ keepalive: true
143
+ }).catch(() => {
144
+ });
145
+ }
136
146
  function baseFields(sessionId, eventType) {
137
147
  return {
138
148
  session_id: sessionId,
@@ -293,7 +303,10 @@ var AfAnalytics = (() => {
293
303
  function startSession(id) {
294
304
  sessionId = id;
295
305
  closeResponder = openSessionResponder(id);
296
- idleTimer = setInterval(touchSession, IDLE_TOUCH_INTERVAL_MS);
306
+ idleTimer = setInterval(() => {
307
+ touchSession();
308
+ sendHeartbeat(apiUrl, apiKey, id);
309
+ }, IDLE_TOUCH_INTERVAL_MS);
297
310
  pending.splice(0).forEach((fn) => fn(id));
298
311
  }
299
312
  function endSession() {
@@ -304,7 +317,7 @@ var AfAnalytics = (() => {
304
317
  idleTimer = null;
305
318
  }
306
319
  closeResponder();
307
- navigator.sendBeacon(`${apiUrl}/sessions/${sessionId}/end`);
320
+ navigator.sendBeacon(`${apiUrl}/sessions/${sessionId}/end`, JSON.stringify({ api_key: apiKey }));
308
321
  clearSession();
309
322
  }
310
323
  function restartSession() {
package/dist/client.js CHANGED
@@ -41,6 +41,12 @@ function getConnectionType() {
41
41
  const nav = navigator;
42
42
  return nav.connection?.effectiveType ?? null;
43
43
  }
44
+ function resolveInteractiveTarget(el) {
45
+ const interactive = el.closest(
46
+ 'a, button, [role="button"], input, select, textarea, label, summary'
47
+ );
48
+ return interactive ?? el;
49
+ }
44
50
  function resolveElementHref(element) {
45
51
  if (element.tagName.toLowerCase() === "a") {
46
52
  return element.href || null;
@@ -48,6 +54,15 @@ function resolveElementHref(element) {
48
54
  const anchor = element.closest("a");
49
55
  return anchor ? anchor.href || null : null;
50
56
  }
57
+ function safeClassName(el) {
58
+ const cn = el.className;
59
+ return typeof cn === "string" ? cn || null : null;
60
+ }
61
+ function cleanLabel(raw, maxLen) {
62
+ if (!raw) return null;
63
+ const cleaned = raw.replace(/\s+/g, " ").trim().slice(0, maxLen);
64
+ return cleaned || null;
65
+ }
51
66
  function isExternalHref(href) {
52
67
  if (!href) return null;
53
68
  if (typeof window === "undefined") return null;
@@ -111,6 +126,17 @@ async function createSession(apiUrl, apiKey, visitorId) {
111
126
  return null;
112
127
  }
113
128
  }
129
+ async function sendHeartbeat(apiUrl, apiKey, sessionId) {
130
+ if (isOptedOut()) return;
131
+ try {
132
+ await fetch(`${apiUrl}/sessions/${sessionId}/heartbeat`, {
133
+ method: "PATCH",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify({ api_key: apiKey })
136
+ });
137
+ } catch {
138
+ }
139
+ }
114
140
  async function sendEvent(apiUrl, apiKey, payload) {
115
141
  if (isOptedOut()) return;
116
142
  await fetch(`${apiUrl}/events`, {
@@ -135,23 +161,24 @@ async function trackPageview(apiUrl, apiKey, sessionId, path, routeName) {
135
161
  });
136
162
  }
137
163
  async function trackClick(apiUrl, apiKey, sessionId, path, e, element) {
138
- const href = resolveElementHref(element);
139
- const ariaLabel = element.getAttribute("aria-label");
140
- const text = element.innerText?.slice(0, 60);
141
- const eventName = ariaLabel || text || element.tagName.toLowerCase();
164
+ const target = resolveInteractiveTarget(element);
165
+ const href = resolveElementHref(target);
166
+ const ariaLabel = target.getAttribute("aria-label");
167
+ const rawText = target.innerText ?? target.textContent ?? void 0;
168
+ const eventName = cleanLabel(ariaLabel, 60) ?? cleanLabel(rawText, 60) ?? target.tagName.toLowerCase();
142
169
  await sendEvent(apiUrl, apiKey, {
143
170
  ...BASE_FIELDS(sessionId, "click", path, eventName, "interaction"),
144
171
  x_position: e.clientX,
145
172
  y_position: e.clientY,
146
- element_id: element.id || null,
147
- element_class: element.className || null,
148
- element_text: element.innerText?.slice(0, 100) || null,
149
- tag_name: element.tagName.toLowerCase(),
173
+ element_id: target.id || null,
174
+ element_class: safeClassName(target),
175
+ element_text: cleanLabel(rawText, 100),
176
+ tag_name: target.tagName.toLowerCase(),
150
177
  element_href: href,
151
- element_role: element.getAttribute("role") || null,
152
- element_aria_label: element.getAttribute("aria-label") || null,
153
- element_type: element.type || null,
154
- element_name: element.name || null,
178
+ element_role: target.getAttribute("role") || null,
179
+ element_aria_label: ariaLabel || null,
180
+ element_type: target.type || null,
181
+ element_name: target.name || null,
155
182
  is_external_link: isExternalHref(href),
156
183
  modifier_keys: {
157
184
  alt: e.altKey,
@@ -400,6 +427,7 @@ function useAnalytics(apiUrl, apiKey, currentPath, wsUrl, routeName) {
400
427
  idleTimerRef.current = setInterval(() => {
401
428
  if (!hasSentEnd.current) {
402
429
  localStorage.setItem("af_session_last_activity", String(Date.now()));
430
+ sendHeartbeat(apiUrl, apiKey, id);
403
431
  }
404
432
  }, IDLE_TOUCH_INTERVAL_MS);
405
433
  if (wsUrl) {
@@ -424,7 +452,7 @@ function useAnalytics(apiUrl, apiKey, currentPath, wsUrl, routeName) {
424
452
  closeResponderRef.current();
425
453
  presenceRef.current?.disconnect();
426
454
  presenceRef.current = null;
427
- navigator.sendBeacon(`${apiUrl}/sessions/${sessionId.current}/end`);
455
+ navigator.sendBeacon(`${apiUrl}/sessions/${sessionId.current}/end`, JSON.stringify({ api_key: apiKey }));
428
456
  localStorage.removeItem("af_session_id");
429
457
  localStorage.removeItem("af_session_last_activity");
430
458
  };
@@ -448,6 +476,7 @@ function useAnalytics(apiUrl, apiKey, currentPath, wsUrl, routeName) {
448
476
  idleTimerRef.current = setInterval(() => {
449
477
  if (!hasSentEnd.current) {
450
478
  localStorage.setItem("af_session_last_activity", String(Date.now()));
479
+ sendHeartbeat(apiUrl, apiKey, id);
451
480
  }
452
481
  }, IDLE_TOUCH_INTERVAL_MS);
453
482
  if (wsUrl) {
@@ -542,6 +571,10 @@ function Analytics({ apiKey, apiUrl, wsUrl, routeName }) {
542
571
  const { trackPageview: trackPageview2, trackClick: trackClick2, trackScroll: trackScroll2, trackCopy: trackCopy2, trackException: trackException2, trackWebVital: trackWebVital2 } = useAnalytics(apiUrl, apiKey, pathname, wsUrl, routeName);
543
572
  const lastTracked = useRef2(null);
544
573
  const lastScrollDepth = useRef2(0);
574
+ const trackWebVitalRef = useRef2(trackWebVital2);
575
+ useEffect2(() => {
576
+ trackWebVitalRef.current = trackWebVital2;
577
+ });
545
578
  useEffect2(() => {
546
579
  if (lastTracked.current === pathname) return;
547
580
  lastTracked.current = pathname;
@@ -551,14 +584,14 @@ function Analytics({ apiKey, apiUrl, wsUrl, routeName }) {
551
584
  useEffect2(() => {
552
585
  if (typeof window === "undefined") return;
553
586
  const report = (name, value, rating) => {
554
- trackWebVital2({ name, value, rating });
587
+ trackWebVitalRef.current({ name, value, rating });
555
588
  };
556
589
  onLCP((m) => report("lcp_ms", Math.round(m.value), m.rating));
557
590
  onCLS((m) => report("cls_score", m.value, m.rating));
558
591
  onINP((m) => report("inp_ms", Math.round(m.value), m.rating));
559
592
  onTTFB((m) => report("ttfb_ms", Math.round(m.value), m.rating));
560
593
  onFCP((m) => report("fcp_ms", Math.round(m.value), m.rating));
561
- }, [trackWebVital2]);
594
+ }, []);
562
595
  useEffect2(() => {
563
596
  if (typeof window === "undefined") return;
564
597
  const handler = (e) => trackClick2(e, e.target);
package/dist/index.js CHANGED
@@ -42,6 +42,12 @@ function getConnectionType() {
42
42
  const nav = navigator;
43
43
  return nav.connection?.effectiveType ?? null;
44
44
  }
45
+ function resolveInteractiveTarget(el) {
46
+ const interactive = el.closest(
47
+ 'a, button, [role="button"], input, select, textarea, label, summary'
48
+ );
49
+ return interactive ?? el;
50
+ }
45
51
  function resolveElementHref(element) {
46
52
  if (element.tagName.toLowerCase() === "a") {
47
53
  return element.href || null;
@@ -49,6 +55,15 @@ function resolveElementHref(element) {
49
55
  const anchor = element.closest("a");
50
56
  return anchor ? anchor.href || null : null;
51
57
  }
58
+ function safeClassName(el) {
59
+ const cn = el.className;
60
+ return typeof cn === "string" ? cn || null : null;
61
+ }
62
+ function cleanLabel(raw, maxLen) {
63
+ if (!raw) return null;
64
+ const cleaned = raw.replace(/\s+/g, " ").trim().slice(0, maxLen);
65
+ return cleaned || null;
66
+ }
52
67
  function isExternalHref(href) {
53
68
  if (!href) return null;
54
69
  if (typeof window === "undefined") return null;
@@ -136,23 +151,24 @@ async function trackPageview(apiUrl, apiKey, sessionId, path, routeName) {
136
151
  });
137
152
  }
138
153
  async function trackClick(apiUrl, apiKey, sessionId, path, e, element) {
139
- const href = resolveElementHref(element);
140
- const ariaLabel = element.getAttribute("aria-label");
141
- const text = element.innerText?.slice(0, 60);
142
- const eventName = ariaLabel || text || element.tagName.toLowerCase();
154
+ const target = resolveInteractiveTarget(element);
155
+ const href = resolveElementHref(target);
156
+ const ariaLabel = target.getAttribute("aria-label");
157
+ const rawText = target.innerText ?? target.textContent ?? void 0;
158
+ const eventName = cleanLabel(ariaLabel, 60) ?? cleanLabel(rawText, 60) ?? target.tagName.toLowerCase();
143
159
  await sendEvent(apiUrl, apiKey, {
144
160
  ...BASE_FIELDS(sessionId, "click", path, eventName, "interaction"),
145
161
  x_position: e.clientX,
146
162
  y_position: e.clientY,
147
- element_id: element.id || null,
148
- element_class: element.className || null,
149
- element_text: element.innerText?.slice(0, 100) || null,
150
- tag_name: element.tagName.toLowerCase(),
163
+ element_id: target.id || null,
164
+ element_class: safeClassName(target),
165
+ element_text: cleanLabel(rawText, 100),
166
+ tag_name: target.tagName.toLowerCase(),
151
167
  element_href: href,
152
- element_role: element.getAttribute("role") || null,
153
- element_aria_label: element.getAttribute("aria-label") || null,
154
- element_type: element.type || null,
155
- element_name: element.name || null,
168
+ element_role: target.getAttribute("role") || null,
169
+ element_aria_label: ariaLabel || null,
170
+ element_type: target.type || null,
171
+ element_name: target.name || null,
156
172
  is_external_link: isExternalHref(href),
157
173
  modifier_keys: {
158
174
  alt: e.altKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthurreira/analytics",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsup",