@arthurreira/analytics 0.16.1 → 0.17.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.
@@ -107,7 +107,7 @@ var AfAnalytics = (() => {
107
107
  session_id: sessionId,
108
108
  event_type: eventType,
109
109
  path: window.location.pathname,
110
- page_url: window.location.href
110
+ page_url: `${window.location.origin}${window.location.pathname}`
111
111
  };
112
112
  }
113
113
  async function createRemoteSession(apiUrl, apiKey, visitorId) {
@@ -136,20 +136,13 @@ var AfAnalytics = (() => {
136
136
  utm_term: params.get("utm_term"),
137
137
  utm_content: params.get("utm_content")
138
138
  };
139
- console.log(`[AF Analytics] Creating session at ${apiUrl}/sessions with API key: ${apiKey.slice(0, 10)}...`);
140
139
  const res = await fetch(`${apiUrl}/sessions`, {
141
140
  method: "POST",
142
141
  headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
143
142
  body: JSON.stringify(body)
144
143
  });
145
- console.log(`[AF Analytics] Session creation response: ${res.status} ${res.statusText}`);
146
- if (!res.ok) {
147
- const errText = await res.text();
148
- console.error(`[AF Analytics] Session creation failed: ${errText}`);
149
- return "";
150
- }
144
+ if (!res.ok) return "";
151
145
  const data = await res.json();
152
- console.log(`[AF Analytics] Session created: ${data.id}`);
153
146
  return data.id;
154
147
  }
155
148
  async function getOrCreateSession(apiUrl, apiKey) {
@@ -209,6 +202,28 @@ var AfAnalytics = (() => {
209
202
  }).catch(() => {
210
203
  });
211
204
  enqueue((id) => trackPageview(apiUrl, apiKey, id));
205
+ let lastPath = window.location.pathname;
206
+ const origPushState = history.pushState.bind(history);
207
+ history.pushState = (...args) => {
208
+ origPushState(...args);
209
+ const newPath = window.location.pathname;
210
+ if (newPath !== lastPath) {
211
+ lastPath = newPath;
212
+ lastScrollDepth = 0;
213
+ enqueue((id) => trackPageview(apiUrl, apiKey, id));
214
+ }
215
+ };
216
+ window.addEventListener("popstate", () => {
217
+ const newPath = window.location.pathname;
218
+ if (newPath !== lastPath) {
219
+ lastPath = newPath;
220
+ lastScrollDepth = 0;
221
+ enqueue((id) => trackPageview(apiUrl, apiKey, id));
222
+ }
223
+ });
224
+ window.addEventListener("hashchange", () => {
225
+ enqueue((id) => trackPageview(apiUrl, apiKey, id));
226
+ });
212
227
  window.addEventListener("click", (e) => {
213
228
  enqueue((id) => trackClick(apiUrl, apiKey, id, e));
214
229
  });
@@ -254,9 +269,18 @@ var AfAnalytics = (() => {
254
269
  navigator.sendBeacon(`${apiUrl}/sessions/${sessionId}/end`);
255
270
  clearSession();
256
271
  };
272
+ let visHideTimer = null;
257
273
  document.addEventListener("visibilitychange", () => {
258
- if (document.visibilityState === "hidden") endSession();
274
+ if (document.visibilityState === "hidden") {
275
+ visHideTimer = setTimeout(endSession, 5e3);
276
+ } else {
277
+ if (visHideTimer !== null) {
278
+ clearTimeout(visHideTimer);
279
+ visHideTimer = null;
280
+ }
281
+ }
259
282
  });
283
+ window.addEventListener("pagehide", endSession);
260
284
  window.addEventListener("beforeunload", endSession);
261
285
  }
262
286
  if (document.readyState === "loading") {
package/dist/client.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  interface AnalyticsProps {
2
2
  apiKey: string;
3
3
  apiUrl: string;
4
+ wsUrl?: string;
4
5
  }
5
- declare function Analytics({ apiKey, apiUrl }: AnalyticsProps): null;
6
+ declare function Analytics({ apiKey, apiUrl, wsUrl }: AnalyticsProps): null;
6
7
 
7
8
  interface ExceptionFields {
8
9
  exception_type: string;
@@ -10,7 +11,7 @@ interface ExceptionFields {
10
11
  stack_trace: string | null;
11
12
  }
12
13
 
13
- declare function useAnalytics(apiUrl: string, apiKey: string): {
14
+ declare function useAnalytics(apiUrl: string, apiKey: string, currentPath: string, wsUrl?: string): {
14
15
  trackPageview: (path: string) => void;
15
16
  trackClick: (e: MouseEvent, element: HTMLElement) => void;
16
17
  trackScroll: (depth: number) => void;
package/dist/client.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  // src/components/Analytics.tsx
5
5
  import { useEffect as useEffect2, useRef as useRef2 } from "react";
6
+ import { usePathname } from "next/navigation";
6
7
 
7
8
  // src/hooks/useAnalytics.ts
8
9
  import { useEffect, useRef } from "react";
@@ -12,7 +13,7 @@ var BASE_FIELDS = (sessionId, eventType, path) => ({
12
13
  session_id: sessionId,
13
14
  event_type: eventType,
14
15
  path,
15
- page_url: typeof window !== "undefined" ? window.location.href : ""
16
+ page_url: typeof window !== "undefined" ? `${window.location.origin}${path}` : ""
16
17
  });
17
18
  function getGpu() {
18
19
  try {
@@ -88,7 +89,6 @@ async function sendEvent(apiUrl, apiKey, payload) {
88
89
  async function trackPageview(apiUrl, apiKey, sessionId, path) {
89
90
  await sendEvent(apiUrl, apiKey, {
90
91
  ...BASE_FIELDS(sessionId, "pageview", path),
91
- page_url: `${typeof window !== "undefined" ? window.location.origin : ""}${path}`,
92
92
  page_title: typeof document !== "undefined" ? document.title || null : null,
93
93
  referrer: typeof document !== "undefined" ? document.referrer || null : null,
94
94
  scroll_depth: 0
@@ -143,6 +143,77 @@ async function trackCTA(apiUrl, apiKey, sessionId, path, ctaId, ctaVariant) {
143
143
  });
144
144
  }
145
145
 
146
+ // src/lib/presence.ts
147
+ var DEFAULT_WS_URL = "wss://edge.arthurreira.dev/realtime";
148
+ function connectPresence(apiKey, sessionId, wsUrl = DEFAULT_WS_URL) {
149
+ if (typeof WebSocket === "undefined") {
150
+ return { disconnect: () => {
151
+ } };
152
+ }
153
+ let ws = null;
154
+ let attempts = 0;
155
+ let intentionalClose = false;
156
+ let retryTimer = null;
157
+ let pingInterval = null;
158
+ let targetUrl;
159
+ try {
160
+ const u = new URL(wsUrl);
161
+ u.searchParams.set("api_key", apiKey);
162
+ u.searchParams.set("session_id", sessionId);
163
+ targetUrl = u.toString();
164
+ } catch {
165
+ return { disconnect: () => {
166
+ } };
167
+ }
168
+ function clearPing() {
169
+ if (pingInterval !== null) {
170
+ clearInterval(pingInterval);
171
+ pingInterval = null;
172
+ }
173
+ }
174
+ function connect() {
175
+ try {
176
+ ws = new WebSocket(targetUrl);
177
+ } catch {
178
+ return;
179
+ }
180
+ ws.onopen = () => {
181
+ attempts = 0;
182
+ ws.send(JSON.stringify({ type: "join", api_key: apiKey, session_id: sessionId }));
183
+ pingInterval = setInterval(() => {
184
+ if (ws?.readyState === WebSocket.OPEN) {
185
+ ws.send(JSON.stringify({ type: "ping", session_id: sessionId }));
186
+ }
187
+ }, 3e4);
188
+ };
189
+ ws.onclose = () => {
190
+ clearPing();
191
+ if (intentionalClose || attempts >= 3) return;
192
+ const delay = Math.pow(2, attempts) * 1e3;
193
+ attempts++;
194
+ retryTimer = setTimeout(connect, delay);
195
+ };
196
+ }
197
+ connect();
198
+ return {
199
+ disconnect: () => {
200
+ intentionalClose = true;
201
+ clearPing();
202
+ if (retryTimer !== null) {
203
+ clearTimeout(retryTimer);
204
+ retryTimer = null;
205
+ }
206
+ if (ws) {
207
+ if (ws.readyState === WebSocket.OPEN) {
208
+ ws.send(JSON.stringify({ type: "leave", session_id: sessionId }));
209
+ }
210
+ ws.close();
211
+ ws = null;
212
+ }
213
+ }
214
+ };
215
+ }
216
+
146
217
  // src/hooks/useAnalytics.ts
147
218
  var SESSION_EXPIRY_MINUTES = 30;
148
219
  var _sessionFlight = null;
@@ -173,11 +244,11 @@ async function getOrCreateSession(apiUrl, apiKey, visitorId) {
173
244
  });
174
245
  return _sessionFlight;
175
246
  }
176
- function useAnalytics(apiUrl, apiKey) {
247
+ function useAnalytics(apiUrl, apiKey, currentPath, wsUrl) {
177
248
  const sessionId = useRef(null);
178
249
  const pendingEvents = useRef([]);
179
- const pathname = useRef(typeof window !== "undefined" ? window?.location?.pathname ?? "" : "");
180
250
  const hasSentEnd = useRef(false);
251
+ const presenceRef = useRef(null);
181
252
  useEffect(() => {
182
253
  if (typeof window === "undefined") return;
183
254
  const visitor_id = localStorage.getItem("af_analytics_visitor_id") || crypto.randomUUID();
@@ -186,6 +257,9 @@ function useAnalytics(apiUrl, apiKey) {
186
257
  getOrCreateSession(apiUrl, apiKey, visitor_id).then((id) => {
187
258
  if (cancelled || !id) return;
188
259
  sessionId.current = id;
260
+ if (wsUrl) {
261
+ presenceRef.current = connectPresence(apiKey, id, wsUrl);
262
+ }
189
263
  pendingEvents.current.forEach((fn) => fn());
190
264
  pendingEvents.current = [];
191
265
  });
@@ -198,19 +272,30 @@ function useAnalytics(apiUrl, apiKey) {
198
272
  const sendEnd = () => {
199
273
  if (!sessionId.current || hasSentEnd.current) return;
200
274
  hasSentEnd.current = true;
275
+ presenceRef.current?.disconnect();
201
276
  navigator.sendBeacon(`${apiUrl}/sessions/${sessionId.current}/end`);
202
277
  localStorage.removeItem("af_session_id");
203
278
  localStorage.removeItem("af_session_last_activity");
204
279
  };
280
+ let visHideTimer = null;
205
281
  const handleVisibility = () => {
206
- if (document.visibilityState === "hidden") sendEnd();
282
+ if (document.visibilityState === "hidden") {
283
+ visHideTimer = setTimeout(sendEnd, 5e3);
284
+ } else {
285
+ if (visHideTimer !== null) {
286
+ clearTimeout(visHideTimer);
287
+ visHideTimer = null;
288
+ }
289
+ }
207
290
  };
208
- const handleUnload = () => sendEnd();
209
291
  document.addEventListener("visibilitychange", handleVisibility);
210
- window.addEventListener("beforeunload", handleUnload);
292
+ window.addEventListener("pagehide", sendEnd);
293
+ window.addEventListener("beforeunload", sendEnd);
211
294
  return () => {
295
+ if (visHideTimer !== null) clearTimeout(visHideTimer);
212
296
  document.removeEventListener("visibilitychange", handleVisibility);
213
- window.removeEventListener("beforeunload", handleUnload);
297
+ window.removeEventListener("pagehide", sendEnd);
298
+ window.removeEventListener("beforeunload", sendEnd);
214
299
  };
215
300
  }, [apiUrl]);
216
301
  function enqueueOrRun(fn) {
@@ -226,38 +311,39 @@ function useAnalytics(apiUrl, apiKey) {
226
311
  enqueueOrRun(() => trackPageview(apiUrl, apiKey, sessionId.current, path));
227
312
  },
228
313
  trackClick: (e, element) => {
229
- enqueueOrRun(() => trackClick(apiUrl, apiKey, sessionId.current, pathname.current, e, element));
314
+ enqueueOrRun(() => trackClick(apiUrl, apiKey, sessionId.current, currentPath, e, element));
230
315
  },
231
316
  trackScroll: (depth) => {
232
- enqueueOrRun(() => trackScroll(apiUrl, apiKey, sessionId.current, pathname.current, depth));
317
+ enqueueOrRun(() => trackScroll(apiUrl, apiKey, sessionId.current, currentPath, depth));
233
318
  },
234
319
  trackCopy: () => {
235
- enqueueOrRun(() => trackCopy(apiUrl, apiKey, sessionId.current, pathname.current));
320
+ enqueueOrRun(() => trackCopy(apiUrl, apiKey, sessionId.current, currentPath));
236
321
  },
237
322
  trackException: (fields) => {
238
- enqueueOrRun(() => trackException(apiUrl, apiKey, sessionId.current, pathname.current, fields));
323
+ enqueueOrRun(() => trackException(apiUrl, apiKey, sessionId.current, currentPath, fields));
239
324
  },
240
325
  trackError: (error) => {
241
- enqueueOrRun(() => trackError(apiUrl, apiKey, sessionId.current, pathname.current, error));
326
+ enqueueOrRun(() => trackError(apiUrl, apiKey, sessionId.current, currentPath, error));
242
327
  },
243
328
  trackCTA: (ctaId, ctaVariant) => {
244
- enqueueOrRun(() => trackCTA(apiUrl, apiKey, sessionId.current, pathname.current, ctaId, ctaVariant));
329
+ enqueueOrRun(() => trackCTA(apiUrl, apiKey, sessionId.current, currentPath, ctaId, ctaVariant));
245
330
  },
246
331
  trackSearch: (query) => {
247
- enqueueOrRun(() => trackSearch(apiUrl, apiKey, sessionId.current, pathname.current, query));
332
+ enqueueOrRun(() => trackSearch(apiUrl, apiKey, sessionId.current, currentPath, query));
248
333
  }
249
334
  };
250
335
  }
251
336
 
252
337
  // src/components/Analytics.tsx
253
- function Analytics({ apiKey, apiUrl }) {
254
- const { trackPageview: trackPageview2, trackClick: trackClick2, trackScroll: trackScroll2, trackCopy: trackCopy2, trackException: trackException2 } = useAnalytics(apiUrl, apiKey);
338
+ function Analytics({ apiKey, apiUrl, wsUrl }) {
339
+ const pathname = usePathname();
340
+ const { trackPageview: trackPageview2, trackClick: trackClick2, trackScroll: trackScroll2, trackCopy: trackCopy2, trackException: trackException2 } = useAnalytics(apiUrl, apiKey, pathname, wsUrl);
255
341
  const lastTracked = useRef2(null);
256
342
  const lastScrollDepth = useRef2(0);
257
- const pathname = typeof window !== "undefined" ? window.location.pathname : "";
258
343
  useEffect2(() => {
259
344
  if (lastTracked.current === pathname) return;
260
345
  lastTracked.current = pathname;
346
+ lastScrollDepth.current = 0;
261
347
  trackPageview2(pathname);
262
348
  }, [pathname, trackPageview2]);
263
349
  useEffect2(() => {
@@ -269,12 +355,10 @@ function Analytics({ apiKey, apiUrl }) {
269
355
  useEffect2(() => {
270
356
  if (typeof window === "undefined") return;
271
357
  const handler = () => {
272
- const scrolled = window.scrollY;
273
358
  const total = document.documentElement.scrollHeight - window.innerHeight;
274
359
  if (total <= 0) return;
275
- const depth = Math.round(scrolled / total * 100);
276
- const milestones = [25, 50, 75, 100];
277
- for (const m of milestones) {
360
+ const depth = Math.round(window.scrollY / total * 100);
361
+ for (const m of [25, 50, 75, 100]) {
278
362
  if (depth >= m && lastScrollDepth.current < m) {
279
363
  lastScrollDepth.current = m;
280
364
  trackScroll2(m);
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ var BASE_FIELDS = (sessionId, eventType, path) => ({
3
3
  session_id: sessionId,
4
4
  event_type: eventType,
5
5
  path,
6
- page_url: typeof window !== "undefined" ? window.location.href : ""
6
+ page_url: typeof window !== "undefined" ? `${window.location.origin}${path}` : ""
7
7
  });
8
8
  function getGpu() {
9
9
  try {
@@ -79,7 +79,6 @@ async function sendEvent(apiUrl, apiKey, payload) {
79
79
  async function trackPageview(apiUrl, apiKey, sessionId, path) {
80
80
  await sendEvent(apiUrl, apiKey, {
81
81
  ...BASE_FIELDS(sessionId, "pageview", path),
82
- page_url: `${typeof window !== "undefined" ? window.location.origin : ""}${path}`,
83
82
  page_title: typeof document !== "undefined" ? document.title || null : null,
84
83
  referrer: typeof document !== "undefined" ? document.referrer || null : null,
85
84
  scroll_depth: 0
@@ -145,6 +144,7 @@ function connectPresence(apiKey, sessionId, wsUrl = DEFAULT_WS_URL) {
145
144
  let attempts = 0;
146
145
  let intentionalClose = false;
147
146
  let retryTimer = null;
147
+ let pingInterval = null;
148
148
  let targetUrl;
149
149
  try {
150
150
  const u = new URL(wsUrl);
@@ -155,6 +155,12 @@ function connectPresence(apiKey, sessionId, wsUrl = DEFAULT_WS_URL) {
155
155
  return { disconnect: () => {
156
156
  } };
157
157
  }
158
+ function clearPing() {
159
+ if (pingInterval !== null) {
160
+ clearInterval(pingInterval);
161
+ pingInterval = null;
162
+ }
163
+ }
158
164
  function connect() {
159
165
  try {
160
166
  ws = new WebSocket(targetUrl);
@@ -164,8 +170,14 @@ function connectPresence(apiKey, sessionId, wsUrl = DEFAULT_WS_URL) {
164
170
  ws.onopen = () => {
165
171
  attempts = 0;
166
172
  ws.send(JSON.stringify({ type: "join", api_key: apiKey, session_id: sessionId }));
173
+ pingInterval = setInterval(() => {
174
+ if (ws?.readyState === WebSocket.OPEN) {
175
+ ws.send(JSON.stringify({ type: "ping", session_id: sessionId }));
176
+ }
177
+ }, 3e4);
167
178
  };
168
179
  ws.onclose = () => {
180
+ clearPing();
169
181
  if (intentionalClose || attempts >= 3) return;
170
182
  const delay = Math.pow(2, attempts) * 1e3;
171
183
  attempts++;
@@ -176,11 +188,15 @@ function connectPresence(apiKey, sessionId, wsUrl = DEFAULT_WS_URL) {
176
188
  return {
177
189
  disconnect: () => {
178
190
  intentionalClose = true;
191
+ clearPing();
179
192
  if (retryTimer !== null) {
180
193
  clearTimeout(retryTimer);
181
194
  retryTimer = null;
182
195
  }
183
196
  if (ws) {
197
+ if (ws.readyState === WebSocket.OPEN) {
198
+ ws.send(JSON.stringify({ type: "leave", session_id: sessionId }));
199
+ }
184
200
  ws.close();
185
201
  ws = null;
186
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthurreira/analytics",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsup",