UNPKG

@jillen/analytics

Version:

Lightweight human-visit and Web Vitals analytics for React apps. Framework-agnostic core with a Next.js adapter.

1,124 lines (1,112 loc) 39.5 kB
"use client"; 'use strict'; var navigation = require('next/navigation'); var isbot = require('isbot'); var react = require('react'); var webVitals = require('web-vitals'); // src/visitor-tracker-next.tsx // src/analytics-host-utils.ts function getSiteIdWithFallback(host) { if (host && host !== "unknown") { return host; } return "unknown"; } // src/resource-classification.ts function isStaticAsset(url) { const staticExtensions = [ ".js", ".css", ".woff2", ".woff", ".ttf", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".ico", ".gif", ".bmp" ]; const staticPatterns = [ "/_next/static/", "/static/", "/assets/", "/public/" ]; if (staticExtensions.some((ext) => url.endsWith(ext))) { return true; } if (staticPatterns.some((pattern) => url.includes(pattern))) { return true; } return false; } function isDynamicEndpoint(url) { if (isStaticAsset(url)) { return false; } const dynamicPatterns = [ "/api/", "/v1/", "/log/", "/analytics/", "/auth/", "/sign" ]; if (url.includes("/dashboard") && !isStaticAsset(url)) { return true; } return dynamicPatterns.some((pattern) => url.includes(pattern)); } function classifyResourcePhase(resource) { const totalRequests = (resource.initial_requests || 0) + (resource.dynamic_requests || 0); if (resource.initiator_type === "fetch" || resource.initiator_type === "xmlhttprequest") { if (totalRequests > 1 || isDynamicEndpoint(resource.name)) { return "dynamic"; } } if ((resource.dynamic_requests || 0) > (resource.initial_requests || 0)) { return "dynamic"; } if (isDynamicEndpoint(resource.name) && (resource.dynamic_requests || 0) > 0) { return "dynamic"; } return "initial"; } // src/performance-collector.ts var globalWebVitals = {}; var webVitalsInitialized = false; function roundCLS(value) { return Math.round(value * 1e3) / 1e3; } function sendMetricsToAnalytics(metric) { if (metric.name === "LCP") globalWebVitals.lcp = Math.round(metric.value); if (metric.name === "CLS") globalWebVitals.cls = roundCLS(metric.value); if (metric.name === "INP") globalWebVitals.inp = Math.round(metric.value); } function initializeWebVitals() { if (webVitalsInitialized || typeof window === "undefined") return; webVitalsInitialized = true; webVitals.onLCP(sendMetricsToAnalytics, { reportAllChanges: true }); webVitals.onCLS(sendMetricsToAnalytics, { reportAllChanges: true }); webVitals.onINP(sendMetricsToAnalytics, { reportAllChanges: true }); } if (typeof window !== "undefined") { initializeWebVitals(); } function calculatePerformanceGrade(m) { let score = 0; let maxScore = 3; if (m.total_page_load !== void 0 && m.total_page_load <= 2500) score++; if (m.lcp !== void 0 && m.lcp <= 2500) score++; if (m.cls !== void 0 && m.cls <= 0.1) score++; if (m.inp !== void 0) { maxScore++; if (m.inp <= 200) score++; } if (score / maxScore >= 0.75) return "good"; if (score / maxScore >= 0.5) return "needs work"; return "poor"; } function calculateAsyncWindow(resources, loadEventEnd, maxWindowMs = 6e4) { const pageResources = resources.filter( (r) => r.startTime <= loadEventEnd + maxWindowMs ); if (pageResources.length === 0) { return { start: void 0, end: void 0, duration: void 0, window: void 0 }; } const start = Math.round(pageResources[0].startTime - loadEventEnd); const end = Math.round(Math.max(...pageResources.map((r) => r.responseEnd)) - loadEventEnd); const window2 = end - start; return { start, end, duration: end, window: window2 }; } function getWebVitalsFromPerformanceAPI() { const perf = window.performance; const result = {}; const lcpEntries = perf.getEntriesByType("largest-contentful-paint"); if (lcpEntries.length > 0) result.lcp = Math.round(lcpEntries[lcpEntries.length - 1].startTime); let clsValue = 0; const layoutShiftEntries = perf.getEntriesByType("layout-shift"); layoutShiftEntries.forEach((entry) => { const layoutEntry = entry; if (!layoutEntry.hadRecentInput && layoutEntry.value) clsValue += layoutEntry.value; }); if (clsValue > 0) result.cls = roundCLS(clsValue); if (globalWebVitals.inp) result.inp = globalWebVitals.inp; return result; } function classifyResourcePhase2(resource) { resource.initial_requests + resource.dynamic_requests; const phase = classifyResourcePhase({ initiator_type: resource.initiator_type, name: resource.name, initial_requests: resource.initial_requests, dynamic_requests: resource.dynamic_requests }); return phase; } function selectRepresentativeResources(resourceMap) { const allResources = Object.values(resourceMap); for (const resource of allResources) { resource.primary_phase = classifyResourcePhase2(resource); resource.spans_both_phases = resource.initial_requests > 0 && resource.dynamic_requests > 0; } const byTypeAndPhase = {}; for (const resource of allResources) { const type = resource.initiator_type; if (!byTypeAndPhase[type]) { byTypeAndPhase[type] = { initial: [], dynamic: [] }; } if (resource.primary_phase === "dynamic") { byTypeAndPhase[type].dynamic.push(resource); } else { byTypeAndPhase[type].initial.push(resource); } } for (const type in byTypeAndPhase) { byTypeAndPhase[type].initial.sort((a, b) => b.total_duration - a.total_duration); byTypeAndPhase[type].dynamic.sort((a, b) => b.total_duration - a.total_duration); } const selected = []; const resourceTypeConfig = { // Usual suspects - often slow, capture more "fetch": { initial: 2, dynamic: 5 }, // API calls "xmlhttprequest": { initial: 2, dynamic: 5 }, // API calls "script": { initial: 3, dynamic: 3 }, // JavaScript "img": { initial: 2, dynamic: 2 }, // Images // Critical but often fast - ensure representation "stylesheet": { initial: 2, dynamic: 1 }, // CSS files "link": { initial: 2, dynamic: 1 }, // Fonts, preloads "font": { initial: 2, dynamic: 1 }, // Web fonts // Less common but important to track "navigation": { initial: 1, dynamic: 0 }, // Main document "other": { initial: 1, dynamic: 1 } // Miscellaneous }; for (const [type, limits] of Object.entries(resourceTypeConfig)) { const groups = byTypeAndPhase[type]; if (!groups) continue; if (groups.initial.length > 0) { const count = Math.min(limits.initial, groups.initial.length); selected.push(...groups.initial.slice(0, count)); } if (groups.dynamic.length > 0) { const count = Math.min(limits.dynamic, groups.dynamic.length); selected.push(...groups.dynamic.slice(0, count)); } } for (const [type, groups] of Object.entries(byTypeAndPhase)) { if (!resourceTypeConfig[type]) { if (groups.initial.length > 0) selected.push(groups.initial[0]); if (groups.dynamic.length > 0) selected.push(groups.dynamic[0]); } } if (selected.length < 15) { const missingTypes = ["stylesheet", "navigation", "img", "font"]; for (const missingType of missingTypes) { const hasType = selected.some((r) => r.initiator_type === missingType); if (!hasType && byTypeAndPhase[missingType]) { const initialResources = byTypeAndPhase[missingType].initial; const dynamicResources = byTypeAndPhase[missingType].dynamic; if (initialResources.length > 0) { selected.push(initialResources[initialResources.length - 1]); } else if (dynamicResources.length > 0) { selected.push(dynamicResources[dynamicResources.length - 1]); } } } } return selected.sort((a, b) => b.total_duration - a.total_duration).slice(0, 35).map((r) => { const totalRequests = r.initial_requests + r.dynamic_requests; const result = { name: r.name, initiator_type: r.initiator_type, duration: Math.round(r.total_duration / totalRequests), transfer_size: Math.round(r.transfer_size / totalRequests), decoded_body_size: Math.round(r.decoded_body_size / totalRequests), domain: r.domain, time_since_load: r.time_since_load, is_async: r.primary_phase === "dynamic" }; if (totalRequests > 1) { result.duplicate_count = totalRequests; result.initial_hits = r.initial_requests; result.dynamic_hits = r.dynamic_requests; } return result; }); } function collectPerfMetrics() { if (typeof window === "undefined" || !window.performance) return null; const navigation = window.performance.getEntriesByType("navigation")[0]; if (!navigation) return null; const resources = window.performance.getEntriesByType("resource"); const totalSizeBytes = resources.reduce( (sum, r) => sum + (r.transferSize || r.encodedBodySize || r.decodedBodySize || 0), 0 ); const webVitals = Object.keys(globalWebVitals).length > 0 ? globalWebVitals : getWebVitalsFromPerformanceAPI(); const dnsLookup = navigation.domainLookupEnd > 0 && navigation.domainLookupStart > 0 ? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart) : void 0; const tcpConnect = navigation.connectEnd > 0 && navigation.connectStart > 0 ? Math.round(navigation.connectEnd - navigation.connectStart) : void 0; const rawTtfb = navigation.responseStart - navigation.requestStart; const rawHtmlResponse = navigation.responseEnd - navigation.responseStart; const rawTotalPageLoad = navigation.loadEventEnd - navigation.fetchStart; const rawFrontendRender = navigation.loadEventEnd - navigation.responseStart; const ttfb = rawTtfb >= 0 ? Math.round(rawTtfb) : void 0; const htmlResponse = rawHtmlResponse >= 0 ? Math.round(rawHtmlResponse) : void 0; const totalPageLoad = rawTotalPageLoad >= 0 ? Math.round(rawTotalPageLoad) : void 0; let frontendRender; if (rawFrontendRender >= 0) { frontendRender = Math.round(rawFrontendRender); if (ttfb !== void 0 && htmlResponse !== void 0 && totalPageLoad !== void 0) { const maxFrontendRender = totalPageLoad - ttfb - htmlResponse; if (frontendRender > maxFrontendRender) { frontendRender = Math.max(0, maxFrontendRender); } } } const asyncResources = resources.filter((r) => r.startTime > navigation.loadEventEnd); const asyncTiming = calculateAsyncWindow(asyncResources, navigation.loadEventEnd, 6e4); const asyncTimelineStart = asyncTiming.start; const asyncTimelineEnd = asyncTiming.end; const asyncTimelineWindow = asyncTiming.window; const resourceMap = {}; const asyncApiResources = asyncResources.filter( (r) => (r.initiatorType === "fetch" || r.initiatorType === "xmlhttprequest") && r.startTime <= navigation.loadEventEnd + 6e4 ); const asyncNonApiResources = asyncResources.filter( (r) => r.initiatorType !== "fetch" && r.initiatorType !== "xmlhttprequest" && r.startTime <= navigation.loadEventEnd + 6e4 ); const asyncApiDomains = {}; const asyncApiEndpointsMap = {}; let asyncApiSlowest; for (const r of asyncApiResources) { const duration = Math.round(r.responseEnd - r.startTime); const url = r.name.split("?")[0]; const domain = new URL(r.name).hostname; if (!asyncApiDomains[domain]) { asyncApiDomains[domain] = { count: 0, totalDuration: 0 }; } asyncApiDomains[domain].count += 1; asyncApiDomains[domain].totalDuration += duration; if (!asyncApiEndpointsMap[url]) { asyncApiEndpointsMap[url] = { duration: 0, count: 0 }; } asyncApiEndpointsMap[url].duration += duration; asyncApiEndpointsMap[url].count += 1; if (!asyncApiSlowest || duration > asyncApiSlowest.duration) { asyncApiSlowest = { url, duration, domain }; } } const asyncApiCount = asyncApiResources.length; const apiTiming = calculateAsyncWindow(asyncApiResources, navigation.loadEventEnd, 6e4); const asyncApiEnd = apiTiming.duration; let asyncApiParallelism; if (asyncApiCount > 0 && asyncApiEnd && asyncApiEnd > 0) { const medianApiTime = asyncApiResources.length > 0 ? asyncApiResources.map((r) => r.responseEnd - r.startTime).sort((a, b) => a - b)[Math.floor(asyncApiResources.length / 2)] : 0; if (medianApiTime > 0) { asyncApiParallelism = Math.round(asyncApiCount / (asyncApiEnd / medianApiTime) * 100) / 100; } } const asyncApiSlowestEndpoints = Object.entries(asyncApiEndpointsMap).map(([url, data]) => ({ url, duration: Math.round(data.duration / data.count), count: data.count })).sort((a, b) => b.duration - a.duration).slice(0, 10); const asyncAssetByType = {}; let asyncAssetDuration = 0; for (const r of asyncNonApiResources) { const duration = Math.round(r.responseEnd - r.startTime); const type = r.initiatorType || "other"; if (!asyncAssetByType[type]) { asyncAssetByType[type] = { count: 0, totalDuration: 0 }; } asyncAssetByType[type].count += 1; asyncAssetByType[type].totalDuration += duration; asyncAssetDuration += duration; } const asyncAssetCount = asyncNonApiResources.length; const asyncAssetSlowest = asyncNonApiResources.sort((a, b) => b.responseEnd - b.startTime - (a.responseEnd - a.startTime)).slice(0, 5).map((r) => ({ name: r.name.split("?")[0], initiator_type: r.initiatorType, duration: Math.round(r.responseEnd - r.startTime) })); for (const r of resources) { const url = r.name.split("?")[0]; const domain = new URL(r.name).hostname; const duration = Math.round(r.responseEnd - r.startTime); const timeSinceLoad = Math.round(r.startTime - navigation.loadEventEnd); const key = url; if (r.startTime > navigation.loadEventEnd + 6e4) { continue; } const isInitialRequest = r.startTime <= navigation.loadEventEnd + 1e3; if (!resourceMap[key]) { resourceMap[key] = { name: url, initiator_type: r.initiatorType, total_duration: 0, initial_requests: 0, dynamic_requests: 0, transfer_size: 0, decoded_body_size: 0, domain, first_start_time: r.startTime, last_start_time: r.startTime, time_since_load: timeSinceLoad, primary_phase: "initial", // Will be determined later spans_both_phases: false }; } const rec = resourceMap[key]; if (isInitialRequest) { rec.initial_requests += 1; } else { rec.dynamic_requests += 1; } rec.first_start_time = Math.min(rec.first_start_time, r.startTime); rec.last_start_time = Math.max(rec.last_start_time, r.startTime); rec.total_duration += duration; rec.transfer_size += r.transferSize || 0; rec.decoded_body_size += r.decodedBodySize || 0; } const selectedResources = selectRepresentativeResources(resourceMap); const topResources = selectedResources; return { page: window.location.pathname, dns_lookup: dnsLookup, tcp_connect: tcpConnect, ttfb, html_response: htmlResponse, frontend_render: frontendRender, total_page_load: totalPageLoad, lcp: webVitals.lcp, cls: webVitals.cls, inp: webVitals.inp, num_requests: resources.length, total_size_kb: Math.round(totalSizeBytes / 1024 * 10) / 10, performance_grade: calculatePerformanceGrade({ total_page_load: totalPageLoad, lcp: webVitals.lcp, cls: webVitals.cls, inp: webVitals.inp }), top_resources: topResources, // Timeline positioning (relative to page load completion) async_timeline_start: asyncTimelineStart, async_timeline_end: asyncTimelineEnd, async_timeline_window: asyncTimelineWindow, // API timeline and analysis async_api_end: asyncApiEnd, async_api_count: asyncApiCount > 0 ? asyncApiCount : void 0, async_api_parallelism: asyncApiParallelism, async_api_domains: asyncApiCount > 0 ? asyncApiDomains : void 0, async_api_slowest_endpoints: asyncApiSlowestEndpoints.length > 0 ? asyncApiSlowestEndpoints : void 0, async_api_slowest: asyncApiSlowest, // Asset analysis (non-API resources) async_asset_duration: asyncAssetCount > 0 ? asyncAssetDuration : void 0, async_asset_count: asyncAssetCount > 0 ? asyncAssetCount : void 0, async_asset_by_type: asyncAssetCount > 0 ? asyncAssetByType : void 0, async_asset_slowest: asyncAssetSlowest.length > 0 ? asyncAssetSlowest : void 0, timestamp: (/* @__PURE__ */ new Date()).toISOString() }; } // package.json var package_default = { version: "5.0.0"}; // src/version.ts var sdk_version = package_default.version; // src/send.ts async function sendHumanEvent(payload) { const endpoint = "https://analytics.jillen.com/api/human"; const payloadWithVersion = { ...payload, sdk_version }; const data = JSON.stringify(payloadWithVersion); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3e4); const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, mode: "cors", body: data, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { console.error( `[Analytics] Server endpoint error: ${response.status} ${response.statusText} - human event failed` ); return; } } catch (error) { if (error instanceof TypeError) { if (error.message.includes("fetch failed") || error.message.includes("network")) { console.error("[Analytics] Network connectivity error in human event:", error.message); } else { console.error("[Analytics] Request configuration error in human event:", error.message); } } else if (error instanceof DOMException && error.name === "AbortError") { console.error("[Analytics] Human event request timeout after 30 seconds"); } else if (error instanceof Error) { console.error("[Analytics] Human event error:", error.name, error.message); } else { console.error("[Analytics] Unknown error in human event:", error); } return; } } async function sendPerformanceEvent(payload) { const endpoint = "https://analytics.jillen.com/api/perf"; const payloadWithVersion = { ...payload, sdk_version }; const data = JSON.stringify(payloadWithVersion); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3e4); const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, mode: "cors", body: data, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { console.error( `[Performance] Server endpoint error: ${response.status} ${response.statusText} - performance event failed` ); return; } } catch (error) { if (error instanceof TypeError) { if (error.message.includes("fetch failed") || error.message.includes("network")) { console.error( "[Performance] Network connectivity error in performance event:", error.message ); } else { console.error( "[Performance] Request configuration error in performance event:", error.message ); } } else if (error instanceof DOMException && error.name === "AbortError") { console.error("[Performance] Performance event request timeout after 30 seconds"); } else if (error instanceof Error) { console.error("[Performance] Performance event error:", error.name, error.message); } else { console.error("[Performance] Unknown error in performance event:", error); } return; } } // src/storage-utils.ts var ANALYTICS_STORAGE_PREFIX = "analytics_"; var DEFAULT_TTL = { VISITOR: 30 * 24 * 60 * 60 * 1e3 }; var AnalyticsStorage = class { static setItem(key, value, ttlMs = DEFAULT_TTL.VISITOR) { if (!this.isClient) return false; try { const item = { value, expiry: Date.now() + ttlMs }; localStorage.setItem( `${ANALYTICS_STORAGE_PREFIX}${key}`, JSON.stringify(item) ); return true; } catch (error) { console.warn("[Analytics] Failed to set localStorage item:", error); return false; } } static getItem(key) { if (!this.isClient) return null; try { const itemStr = localStorage.getItem(`${ANALYTICS_STORAGE_PREFIX}${key}`); if (!itemStr) return null; const item = JSON.parse(itemStr); if (Date.now() > item.expiry) { localStorage.removeItem(`${ANALYTICS_STORAGE_PREFIX}${key}`); return null; } return item.value; } catch (error) { console.warn("[Analytics] Failed to get localStorage item:", error); return null; } } static removeItem(key) { if (!this.isClient) return; try { localStorage.removeItem(`${ANALYTICS_STORAGE_PREFIX}${key}`); } catch (error) { console.warn("[Analytics] Failed to remove localStorage item:", error); } } static setVisitor(visitorId) { return this.setItem( `visitor_${visitorId}`, Date.now(), DEFAULT_TTL.VISITOR ); } static hasVisitor(visitorId) { return this.getItem(`visitor_${visitorId}`) !== null; } static cleanupOldSessionEntries() { if (!this.isClient) return 0; let cleanedCount = 0; const obsoletePrefixes = [ `${ANALYTICS_STORAGE_PREFIX}session_start_`, `${ANALYTICS_STORAGE_PREFIX}session_active_` ]; try { const keys = Object.keys(localStorage); for (const key of keys) { for (const prefix of obsoletePrefixes) { if (key.startsWith(prefix)) { localStorage.removeItem(key); cleanedCount++; break; } } } } catch (error) { console.warn("[Analytics] Failed to cleanup old session entries:", error); } return cleanedCount; } static cleanupExpiredItems() { if (!this.isClient) return 0; let cleanedCount = 0; try { const keys = Object.keys(localStorage); const now = Date.now(); for (const key of keys) { if (!key.startsWith(ANALYTICS_STORAGE_PREFIX)) continue; try { const itemStr = localStorage.getItem(key); if (!itemStr) continue; const item = JSON.parse(itemStr); if (item.expiry && now > item.expiry) { localStorage.removeItem(key); cleanedCount++; } } catch { localStorage.removeItem(key); cleanedCount++; } } } catch (error) { console.warn("[Analytics] Failed to cleanup expired items:", error); } return cleanedCount; } static migrateOldFormat() { if (!this.isClient) return 0; let migratedCount = 0; const oldPrefixes = [ "analytics_visitor_", "analytics_session_start_", "analytics_session_active_" ]; try { const keys = Object.keys(localStorage); for (const key of keys) { for (const prefix of oldPrefixes) { if (key.startsWith(prefix)) { const value = localStorage.getItem(key); if (value && !value.includes('"expiry":')) { localStorage.removeItem(key); migratedCount++; const keyParts = key.replace(prefix, "").split("_"); if (prefix === "analytics_visitor_" && keyParts.length > 0) { const timestamp = parseInt(value); if (!isNaN(timestamp) && Date.now() - timestamp < DEFAULT_TTL.VISITOR) { this.setVisitor(keyParts.join("_")); } } } } } } } catch (error) { console.warn("[Analytics] Failed to migrate old format:", error); } return migratedCount; } static getStorageSize() { if (!this.isClient) return { count: 0, sizeBytes: 0 }; let count = 0; let sizeBytes = 0; try { const keys = Object.keys(localStorage); for (const key of keys) { if (key.startsWith(ANALYTICS_STORAGE_PREFIX)) { count++; const value = localStorage.getItem(key) || ""; sizeBytes += key.length + value.length; } } } catch (error) { console.warn("[Analytics] Failed to calculate storage size:", error); } return { count, sizeBytes }; } }; AnalyticsStorage.isClient = typeof window !== "undefined"; var AnalyticsSessionStorage = class { static setItem(key, value) { if (!this.isClient) return false; try { sessionStorage.setItem( `${ANALYTICS_STORAGE_PREFIX}${key}`, JSON.stringify(value) ); return true; } catch (error) { console.warn("[Analytics] Failed to set sessionStorage item:", error); return false; } } static getItem(key) { if (!this.isClient) return null; try { const itemStr = sessionStorage.getItem( `${ANALYTICS_STORAGE_PREFIX}${key}` ); if (!itemStr) return null; return JSON.parse(itemStr); } catch (error) { console.warn("[Analytics] Failed to get sessionStorage item:", error); return null; } } static removeItem(key) { if (!this.isClient) return; try { sessionStorage.removeItem(`${ANALYTICS_STORAGE_PREFIX}${key}`); } catch (error) { console.warn("[Analytics] Failed to remove sessionStorage item:", error); } } }; AnalyticsSessionStorage.isClient = typeof window !== "undefined"; // src/visitor-tracker.tsx function generateVisitorId(username) { if (username && username.trim() !== "") { const cleanUsername = username.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-+|-+$/g, "").substring(0, 50) || "unknown-user"; AnalyticsStorage.setItem("visitor_id", cleanUsername); return cleanUsername; } const stored = AnalyticsStorage.getItem("visitor_id"); if (stored && !stored.includes("@") && stored !== "unknown-user") { return stored; } const fingerprint = [ navigator.userAgent || "unknown", navigator.language || "unknown", `${screen.width}x${screen.height}`, navigator.hardwareConcurrency || "unknown", Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown" ].join("|"); let hash = 0; for (let i = 0; i < fingerprint.length; i++) { const char = fingerprint.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } const visitorId = Math.abs(hash).toString(36); AnalyticsStorage.setItem("visitor_id", visitorId); return visitorId; } function generateSessionId() { if (typeof window !== "undefined") { const sessionData = AnalyticsSessionStorage.getItem("session_data"); if (sessionData?.session_id) { const now2 = Date.now(); const timeSinceActivity = now2 - sessionData.last_activity; const SESSION_TIMEOUT = 30 * 60 * 1e3; if (timeSinceActivity < SESSION_TIMEOUT) { return { sessionId: sessionData.session_id, isNewSession: false }; } AnalyticsSessionStorage.removeItem("session_data"); } } const generateLightweightId = () => { try { const array = new Uint8Array(4); crypto.getRandomValues(array); const timestamp = Date.now().toString(36).slice(-4); const randomPart = Array.from(array).map((byte) => byte.toString(36)).join("").slice(0, 4); return `${timestamp}${randomPart}`.substring(0, 8); } catch { const timestamp = Date.now().toString(36).slice(-4); const randomPart = Math.random().toString(36).substring(2, 6); return `${timestamp}${randomPart}`.substring(0, 8); } }; const newSessionId = generateLightweightId(); const now = Date.now(); if (typeof window !== "undefined") { AnalyticsSessionStorage.setItem("session_data", { session_id: newSessionId, last_activity: now }); } return { sessionId: newSessionId, isNewSession: true }; } function getClientData(username) { if (typeof window === "undefined") { return { isNewVisitor: true, screenResolution: null, viewportSize: null, connectionType: null, clientTimeZone: null, sessionStartTime: (/* @__PURE__ */ new Date()).toISOString(), isNewSession: true }; } const visitorId = generateVisitorId(username); const { sessionId, isNewSession } = generateSessionId(); let isNewVisitor; if (username && username.trim() !== "") { isNewVisitor = false; } else { const visitorCacheKey = `isNewVisitor_${visitorId}`; const cachedIsNewVisitor = AnalyticsSessionStorage.getItem(visitorCacheKey); if (cachedIsNewVisitor === null) { const visitorExists = AnalyticsStorage.hasVisitor(visitorId); isNewVisitor = !visitorExists; AnalyticsSessionStorage.setItem(visitorCacheKey, isNewVisitor); if (isNewVisitor) { AnalyticsStorage.setVisitor(visitorId); } } else { isNewVisitor = cachedIsNewVisitor; } } let sessionStartTime = AnalyticsSessionStorage.getItem( `session_start_${sessionId}` ); if (!sessionStartTime) { sessionStartTime = (/* @__PURE__ */ new Date()).toISOString(); AnalyticsSessionStorage.setItem( `session_start_${sessionId}`, sessionStartTime ); } const screenResolution = `${screen.width}x${screen.height}`; const viewportSize = `${window.innerWidth}x${window.innerHeight}`; const isMobile = /Mobile|Android|iPhone|iPad/.test(navigator.userAgent); const connectionType = isMobile ? "mobile" : "desktop"; const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; return { isNewVisitor, screenResolution, viewportSize, connectionType, clientTimeZone, sessionStartTime, isNewSession }; } function VisitorTracker({ username, pathname: rawPathname }) { const pathnameInvalid = typeof rawPathname !== "string"; const pathname = pathnameInvalid ? "" : rawPathname; const warnedRef = react.useRef(false); react.useEffect(() => { if (pathnameInvalid && !warnedRef.current && typeof window !== "undefined") { warnedRef.current = true; console.error( "[Analytics] VisitorTracker requires a `pathname` prop. For Next.js, import from '@jillen/analytics/next' (auto-pathname). For other routers, pass `pathname` from your router state (e.g. `useLocation().pathname` from react-router-dom)." ); } }, [pathnameInvalid]); const isInitialized = react.useRef(false); const lastTrackedPath = react.useRef(pathname); const heartbeatInterval = react.useRef(void 0); const heartbeatEnabled = react.useRef(true); const currentInterval = react.useRef(15e3); const isActive = react.useRef(true); const perfEventSent = react.useRef(false); const sentEventsCache = react.useRef(/* @__PURE__ */ new Map()); const DEDUPE_WINDOW_MS = 100; const isBot = react.useCallback(() => { if (typeof navigator !== "undefined" && isbot.isbot(navigator.userAgent)) { return true; } return false; }, []); const updateLastActivity = react.useCallback(() => { if (typeof window !== "undefined") { const sessionData = AnalyticsSessionStorage.getItem("session_data"); if (sessionData) { sessionData.last_activity = Date.now(); AnalyticsSessionStorage.setItem("session_data", sessionData); } } }, []); const sendPerfEvent = react.useCallback(async () => { if (perfEventSent.current) { return; } perfEventSent.current = true; setTimeout(async () => { try { const siteId = getSiteIdWithFallback(window.location.hostname); const visitorId = generateVisitorId(username); const perfMetrics = collectPerfMetrics(); if (!perfMetrics) return; if (siteId === "analytics.jillen.com" && perfMetrics.page.includes("/performance/")) { return; } const payload = { ...perfMetrics, website_domain: siteId, visitor_id: visitorId }; await sendPerformanceEvent(payload); } catch (error) { console.error("[Performance] Error sending perf event:", error); } }, 1e3); }, [username]); const sendEvent = react.useCallback( async (eventType, referrer) => { if (process.env.NODE_ENV !== "production") { return; } const siteId = getSiteIdWithFallback(window.location.hostname); const { sessionId } = generateSessionId(); const dedupeKey = `${sessionId}_${eventType}_${pathname}`; const now = Date.now(); const lastSent = sentEventsCache.current.get(dedupeKey); if (lastSent && now - lastSent < DEDUPE_WINDOW_MS) { return; } sentEventsCache.current.set(dedupeKey, now); if (sentEventsCache.current.size > 50) { const oldestKey = sentEventsCache.current.keys().next().value; if (oldestKey) { sentEventsCache.current.delete(oldestKey); } } if (eventType === "pageview") { sendPerfEvent(); } try { const clientData = getClientData(username); const visitorId = generateVisitorId(username); const payload = { website_domain: siteId, path: pathname, visitor_id: visitorId, session_id: sessionId, event_type: eventType, is_new_visitor: clientData.isNewVisitor, screen_resolution: clientData.screenResolution, viewport_size: clientData.viewportSize, connection_type: clientData.connectionType, client_time_zone: clientData.clientTimeZone, session_start_time: clientData.sessionStartTime, visitor_name: username ?? void 0, referrer }; await sendHumanEvent(payload); } catch (error) { console.error("[Analytics] Error sending event:", error); } }, [pathname, username, sendPerfEvent] ); const scheduleNextHeartbeat = react.useCallback(() => { if (!heartbeatEnabled.current || typeof window === "undefined") return; if (heartbeatInterval.current) { return; } heartbeatInterval.current = setTimeout(() => { heartbeatInterval.current = void 0; const sessionData = AnalyticsSessionStorage.getItem("session_data"); const timeSinceActivity = sessionData ? Date.now() - sessionData.last_activity : 0; const inactivityThreshold = 2 * 60 * 1e3; const SESSION_TIMEOUT = 30 * 60 * 1e3; if (timeSinceActivity >= SESSION_TIMEOUT) { heartbeatEnabled.current = false; return; } if (timeSinceActivity < inactivityThreshold) { sendEvent("heartbeat"); } const intervals = [15e3, 6e4, 5 * 60 * 1e3, 15 * 60 * 1e3]; const currentIndex = intervals.indexOf(currentInterval.current); if (currentIndex !== -1 && currentIndex < intervals.length - 1) { currentInterval.current = intervals[currentIndex + 1]; } scheduleNextHeartbeat(); }, currentInterval.current); }, [sendEvent]); const handleActivity = react.useCallback(() => { const now = Date.now(); isActive.current = true; const sessionData = AnalyticsSessionStorage.getItem("session_data"); const timeSinceActivity = sessionData ? now - sessionData.last_activity : Infinity; const SESSION_TIMEOUT = 30 * 60 * 1e3; if (timeSinceActivity > SESSION_TIMEOUT) { const { isNewSession } = generateSessionId(); if (isNewSession) { sendEvent("session_start"); sendEvent("pageview"); } heartbeatEnabled.current = true; } updateLastActivity(); currentInterval.current = 15e3; if (heartbeatEnabled.current) { scheduleNextHeartbeat(); } }, [scheduleNextHeartbeat, sendEvent, updateLastActivity]); const throttleRef = react.useRef({ lastExecTime: 0 }); const throttledHandleActivity = react.useCallback(() => { const delay = 250; const currentTime = Date.now(); if (currentTime - throttleRef.current.lastExecTime > delay) { handleActivity(); throttleRef.current.lastExecTime = currentTime; } else { if (throttleRef.current.timeoutId) { clearTimeout(throttleRef.current.timeoutId); } throttleRef.current.timeoutId = setTimeout(() => { handleActivity(); throttleRef.current.lastExecTime = Date.now(); }, delay - (currentTime - throttleRef.current.lastExecTime)); } }, [handleActivity]); react.useEffect(() => { if (heartbeatInterval.current) { clearTimeout(heartbeatInterval.current); heartbeatInterval.current = void 0; } if (lastTrackedPath.current !== pathname) { perfEventSent.current = false; } if (pathnameInvalid) return; if (process.env.NODE_ENV !== "production") return; if (isBot()) { return; } const clientData = getClientData(username); const isNewSession = clientData.isNewSession; if (isNewSession && !isInitialized.current) { sendEvent("session_start"); isInitialized.current = true; } if (isNewSession || lastTrackedPath.current !== pathname) { updateLastActivity(); const referrer = typeof window !== "undefined" && document.referrer && document.referrer.length > 0 ? document.referrer : void 0; sendEvent("pageview", referrer); lastTrackedPath.current = pathname; } if (typeof window !== "undefined") { const events = [ "click", "keydown", "touchstart", "scroll", "wheel", "play", "pause", "seeked", "volumechange", "input", "change", "focus", "copy", "cut", "paste" ]; events.forEach((event) => { window.addEventListener(event, throttledHandleActivity, { passive: true }); }); const handleVisibilityChange = () => { if (document.hidden) { heartbeatEnabled.current = false; if (heartbeatInterval.current) { clearTimeout(heartbeatInterval.current); heartbeatInterval.current = void 0; } } else { heartbeatEnabled.current = true; isActive.current = true; scheduleNextHeartbeat(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); scheduleNextHeartbeat(); return () => { events.forEach((event) => { window.removeEventListener(event, throttledHandleActivity); }); document.removeEventListener( "visibilitychange", handleVisibilityChange ); if (heartbeatInterval.current) { clearTimeout(heartbeatInterval.current); } }; } return void 0; }, [ pathname, username, isBot, // Start the dynamic heartbeat system scheduleNextHeartbeat, sendEvent, throttledHandleActivity, updateLastActivity ]); return null; } // src/visitor-tracker-next.tsx function VisitorTracker2(props) { const pathname = navigation.usePathname(); return /* @__PURE__ */ React.createElement(VisitorTracker, { ...props, pathname: pathname ?? "" }); } exports.VisitorTracker = VisitorTracker2; //# sourceMappingURL=next.js.map //# sourceMappingURL=next.js.map