UNPKG

@flavoai/fastfold

Version:

Flavo frontend package

503 lines 17.2 kB
// ============================================================================ // FASTFOLD OBSERVABILITY - Analytics & Error Tracking // ============================================================================ // ============================================================================ // REFERRER PARSING // ============================================================================ const REFERRER_PATTERNS = [ [/facebook\.com|fb\.com|fbcdn/, 'facebook'], [/twitter\.com|t\.co|x\.com/, 'twitter'], [/instagram\.com/, 'instagram'], [/google\.|googleapis/, 'google'], [/bing\.com/, 'bing'], [/linkedin\.com/, 'linkedin'], [/youtube\.com/, 'youtube'], [/reddit\.com/, 'reddit'], [/tiktok\.com/, 'tiktok'], [/pinterest\.com/, 'pinterest'], ]; function parseReferrer(referrer) { if (!referrer) return 'direct'; for (const [pattern, source] of REFERRER_PATTERNS) { if (pattern.test(referrer)) return source; } return 'other'; } // ============================================================================ // UUID GENERATION (browser-compatible) // ============================================================================ function generateUUID() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } // Fallback for older browsers return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } // ============================================================================ // OBSERVABILITY MANAGER // ============================================================================ export class ObservabilityManager { config; sessionId; visitorId; userId = null; userEmail = null; pageviewCount = 0; eventBuffer = []; errorCount = 0; eventCount = 0; flushTimer = null; isInitialized = false; sessionStartTime = 0; sessionEnded = false; // Defaults static DEFAULT_SESSION_TIMEOUT_MINUTES = 30; static DEFAULT_BATCH_INTERVAL_MS = 5000; static DEFAULT_MAX_BATCH_SIZE = 10; static DEFAULT_MAX_EVENTS_PER_SESSION = 1000; static DEFAULT_MAX_ERRORS_PER_SESSION = 100; static DEFAULT_ENDPOINT = '/api/observe'; static DEFAULT_APP_ID = 'local-app'; constructor(config) { this.config = { appId: ObservabilityManager.DEFAULT_APP_ID, endpoint: ObservabilityManager.DEFAULT_ENDPOINT, trackPageviews: true, trackErrors: true, trackCustomEvents: true, sessionTimeoutMinutes: ObservabilityManager.DEFAULT_SESSION_TIMEOUT_MINUTES, batchIntervalMs: ObservabilityManager.DEFAULT_BATCH_INTERVAL_MS, maxBatchSize: ObservabilityManager.DEFAULT_MAX_BATCH_SIZE, maxEventsPerSession: ObservabilityManager.DEFAULT_MAX_EVENTS_PER_SESSION, maxErrorsPerSession: ObservabilityManager.DEFAULT_MAX_ERRORS_PER_SESSION, ...config, }; // Initialize identities this.visitorId = this.getOrCreateVisitorId(); this.sessionId = this.getOrCreateSessionId(); this.sessionStartTime = Date.now(); // Setup only in browser environment if (typeof window !== 'undefined' && this.config.enabled) { this.initialize(); } } /** * Initialize observability - setup error handlers, track session start */ initialize() { if (this.isInitialized) return; this.isInitialized = true; // Setup error handlers if (this.config.trackErrors) { this.setupErrorHandlers(); } // Track session start this.send({ event_type: 'session_start', referrer: document.referrer, }); // Track initial pageview if (this.config.trackPageviews) { this.trackPageview(); } // Setup visibility tracking for session end this.setupVisibilityTracking(); // Setup SPA navigation tracking this.setupNavigationTracking(); // Setup periodic flush this.startFlushTimer(); // Flush on page unload this.setupUnloadHandler(); } /** * Get or create persistent visitor ID from localStorage */ getOrCreateVisitorId() { if (typeof localStorage === 'undefined') { return generateUUID(); } const key = 'sb_visitor_id'; let visitorId = localStorage.getItem(key); if (!visitorId) { visitorId = generateUUID(); localStorage.setItem(key, visitorId); } return visitorId; } /** * Get or create session ID with hybrid timeout logic */ getOrCreateSessionId() { if (typeof sessionStorage === 'undefined') { return generateUUID(); } const sessionKey = 'sb_session_id'; const activityKey = 'sb_last_activity'; const countKey = 'sb_pageview_count'; const lastActivity = sessionStorage.getItem(activityKey); const existingSessionId = sessionStorage.getItem(sessionKey); const existingCount = sessionStorage.getItem(countKey); const now = Date.now(); const timeoutMs = (this.config.sessionTimeoutMinutes || 30) * 60 * 1000; // Check if session has timed out if (lastActivity) { const elapsed = now - parseInt(lastActivity, 10); if (elapsed > timeoutMs) { // Session timed out - start new session const newSessionId = generateUUID(); sessionStorage.setItem(sessionKey, newSessionId); sessionStorage.setItem(countKey, '0'); this.pageviewCount = 0; sessionStorage.setItem(activityKey, now.toString()); return newSessionId; } } // Update last activity sessionStorage.setItem(activityKey, now.toString()); // Restore pageview count if (existingCount) { this.pageviewCount = parseInt(existingCount, 10); } // Use existing session or create new one if (existingSessionId) { return existingSessionId; } const newSessionId = generateUUID(); sessionStorage.setItem(sessionKey, newSessionId); sessionStorage.setItem(countKey, '0'); return newSessionId; } /** * Update last activity timestamp (called on user interactions) */ updateActivity() { if (typeof sessionStorage !== 'undefined') { sessionStorage.setItem('sb_last_activity', Date.now().toString()); } } /** * Setup global error handlers */ setupErrorHandlers() { // Handle synchronous errors window.addEventListener('error', (event) => { this.trackError({ name: 'Error', message: event.message, stack: event.error?.stack || `${event.filename}:${event.lineno}:${event.colno}`, }, { source: 'frontend' }); }); // Handle unhandled promise rejections window.addEventListener('unhandledrejection', (event) => { const error = event.reason; this.trackError({ name: 'UnhandledRejection', message: error?.message || String(error), stack: error?.stack, }, { source: 'frontend' }); }); } /** * Setup visibility change tracking for session end */ setupVisibilityTracking() { document.addEventListener('visibilitychange', () => { if (document.hidden) { // Only send session_end once per session if (!this.sessionEnded) { this.sessionEnded = true; // User is leaving - send session end with duration const duration = Math.round((Date.now() - this.sessionStartTime) / 1000); this.send({ event_type: 'session_end', pageview_count: this.pageviewCount, properties: { duration_seconds: duration }, }); } this.flush(); } else { // User is back - update activity this.updateActivity(); } }); } /** * Setup SPA navigation tracking via History API */ setupNavigationTracking() { // Listen for popstate (back/forward navigation) window.addEventListener('popstate', () => { if (this.config.trackPageviews) { this.trackPageview(); } }); // Monkey-patch pushState for client-side navigation const originalPushState = history.pushState.bind(history); history.pushState = (...args) => { originalPushState(...args); if (this.config.trackPageviews) { // Small delay to let the URL update setTimeout(() => this.trackPageview(), 0); } }; // Monkey-patch replaceState const originalReplaceState = history.replaceState.bind(history); history.replaceState = (...args) => { originalReplaceState(...args); if (this.config.trackPageviews) { setTimeout(() => this.trackPageview(), 0); } }; } /** * Setup unload handler to flush remaining events */ setupUnloadHandler() { window.addEventListener('beforeunload', () => { this.flush(); }); // Also handle pagehide for mobile browsers window.addEventListener('pagehide', () => { this.flush(); }); } /** * Start the periodic flush timer */ startFlushTimer() { if (this.flushTimer) { clearInterval(this.flushTimer); } this.flushTimer = setInterval(() => { this.flush(); }, this.config.batchIntervalMs || ObservabilityManager.DEFAULT_BATCH_INTERVAL_MS); } /** * Send an event to the buffer */ send(event) { // Check event limits if (this.eventCount >= (this.config.maxEventsPerSession || ObservabilityManager.DEFAULT_MAX_EVENTS_PER_SESSION)) { return; } const url = typeof window !== 'undefined' ? window.location.href : ''; const pagePath = typeof window !== 'undefined' ? window.location.pathname : '/'; const fullEvent = { event_id: generateUUID(), app_id: this.config.appId, session_id: this.sessionId, visitor_id: this.visitorId, user_id: this.userId || undefined, user_email: this.userEmail || undefined, event_type: 'custom', timestamp: new Date().toISOString(), url, page_path: pagePath, user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '', ...event, }; this.eventBuffer.push(fullEvent); this.eventCount++; this.updateActivity(); // Check if we should flush immediately if (this.eventBuffer.length >= (this.config.maxBatchSize || ObservabilityManager.DEFAULT_MAX_BATCH_SIZE)) { this.flush(); } } /** * Flush buffered events to the collector */ flush() { if (this.eventBuffer.length === 0) return; const events = [...this.eventBuffer]; this.eventBuffer = []; // Use sendBeacon for non-blocking, reliable delivery if (typeof navigator !== 'undefined' && navigator.sendBeacon) { const blob = new Blob([JSON.stringify(events)], { type: 'application/json' }); navigator.sendBeacon(this.config.endpoint, blob); } else { // Fallback to fetch fetch(this.config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(events), keepalive: true, }).catch(() => { // Silently fail - observability should not break the app }); } } // ============================================================================ // PUBLIC API // ============================================================================ /** * Get the current session ID (for API request headers) */ get currentSessionId() { return this.sessionId; } /** * Get the current visitor ID (for API request headers) */ get currentVisitorId() { return this.visitorId; } /** * Set the authenticated user ID and optional email */ setUser(userId, email) { this.userId = userId; this.userEmail = email || null; } /** * Track a pageview event */ trackPageview(url) { if (!this.config.trackPageviews) return; this.pageviewCount++; // Persist pageview count if (typeof sessionStorage !== 'undefined') { sessionStorage.setItem('sb_pageview_count', this.pageviewCount.toString()); } this.send({ event_type: 'pageview', url: url || (typeof window !== 'undefined' ? window.location.href : ''), // Referrer is tracked once on session_start, not on every pageview pageview_count: this.pageviewCount, }); } /** * Track an error event */ trackError(error, context) { if (!this.config.trackErrors) return; // Check error limit if (this.errorCount >= (this.config.maxErrorsPerSession || ObservabilityManager.DEFAULT_MAX_ERRORS_PER_SESSION)) { return; } this.errorCount++; this.send({ event_type: context.source === 'backend' ? 'error_backend' : 'error_frontend', error: { message: error.message, stack: error.stack, component: context.component, endpoint: context.endpoint, status_code: context.status_code, severity: 'error', }, }); } /** * Track a custom event */ track(eventName, properties) { if (!this.config.trackCustomEvents) return; this.send({ event_type: 'custom', event_name: eventName, properties, }); } /** * Manually flush events (useful before navigation) */ forceFlush() { this.flush(); } /** * Destroy the manager and cleanup */ destroy() { if (this.flushTimer) { clearInterval(this.flushTimer); this.flushTimer = null; } this.flush(); this.isInitialized = false; } } // ============================================================================ // SINGLETON INSTANCE & PUBLIC API // ============================================================================ let observabilityInstance = null; /** * Initialize observability (called by FastfoldProvider) */ export function initializeObservability(config) { if (observabilityInstance) { observabilityInstance.destroy(); } observabilityInstance = new ObservabilityManager(config); return observabilityInstance; } /** * Get the current observability instance */ export function getObservabilityInstance() { return observabilityInstance; } /** * Public observability API for use in apps */ export const observability = { /** * Track a custom event * @example observability.track('signup', { plan: 'pro' }) */ track: (eventName, properties) => { observabilityInstance?.track(eventName, properties); }, /** * Manually track a pageview (for edge cases) * @example observability.trackPageview('/custom-page') */ trackPageview: (url) => { observabilityInstance?.trackPageview(url); }, /** * Set the authenticated user ID and optional email * @example observability.setUser(user.id, user.email) */ setUser: (userId, email) => { observabilityInstance?.setUser(userId, email); }, /** * Track an error (usually called automatically) */ trackError: (error, context) => { observabilityInstance?.trackError(error, context); }, /** * Force flush any buffered events */ flush: () => { observabilityInstance?.forceFlush(); }, /** * Get the current session ID (for API headers) */ get sessionId() { return observabilityInstance?.currentSessionId; }, /** * Get the current visitor ID (for API headers) */ get visitorId() { return observabilityInstance?.currentVisitorId; }, }; //# sourceMappingURL=observability.js.map