@jillen/analytics
Version:
Lightweight human-visit and Web Vitals analytics for React apps. Framework-agnostic core with a Next.js adapter.
1,117 lines (1,106 loc) • 39.2 kB
JavaScript
"use client";
;
var isbot = require('isbot');
var react = require('react');
var webVitals = require('web-vitals');
// src/visitor-tracker.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;
}
exports.VisitorTracker = VisitorTracker;
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map