UNPKG

@tinytapanalytics/sdk

Version:

Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time

539 lines (466 loc) 14.9 kB
/** * Performance Monitoring features for tracking Core Web Vitals and custom metrics * Dynamically imported to reduce core bundle size */ import { TinyTapAnalyticsConfig } from '../types/index'; interface PerformanceMetrics { // Core Web Vitals lcp?: number; // Largest Contentful Paint fid?: number; // First Input Delay cls?: number; // Cumulative Layout Shift fcp?: number; // First Contentful Paint ttfb?: number; // Time to First Byte // Custom metrics domContentLoaded?: number; loadComplete?: number; domInteractive?: number; // Resource timing resourceCount?: number; totalResourceSize?: number; // Navigation timing dnsLookup?: number; tcpConnect?: number; // User timing customMarks?: Record<string, number>; customMeasures?: Record<string, number>; } interface ResourceTiming { name: string; type: string; size: number; duration: number; startTime: number; } export class PerformanceMonitoring { private config: TinyTapAnalyticsConfig; private sdk: any; private metrics: PerformanceMetrics = {}; private isActive = false; private observers: Set<PerformanceObserver> = new Set(); constructor(config: TinyTapAnalyticsConfig, sdk: any) { this.config = config; this.sdk = sdk; } /** * Start performance monitoring */ public start(): void { if (this.isActive || !this.isPerformanceAPIAvailable()) { return; } this.isActive = true; this.collectInitialMetrics(); this.setupCoreWebVitalsObservers(); this.setupResourceTimingObserver(); this.setupNavigationTimingObserver(); this.setupUserTimingObserver(); this.scheduleMetricsCollection(); if (this.config.debug) { console.log('TinyTapAnalytics: Performance monitoring started'); } } /** * Stop performance monitoring */ public stop(): void { if (!this.isActive) { return; } this.isActive = false; this.observers.forEach(observer => observer.disconnect()); this.observers.clear(); this.sendPerformanceData(); if (this.config.debug) { console.log('TinyTapAnalytics: Performance monitoring stopped'); } } /** * Collect initial performance metrics */ private collectInitialMetrics(): void { if (!window.performance || !window.performance.timing) { return; } const timing = window.performance.timing; const navigationStart = timing.navigationStart; // Only collect metrics that are already available // domContentLoaded and loadComplete will be collected after their events fire this.metrics = { domInteractive: timing.domInteractive > 0 ? timing.domInteractive - navigationStart : undefined, dnsLookup: timing.domainLookupEnd - timing.domainLookupStart, tcpConnect: timing.connectEnd - timing.connectStart, ttfb: timing.responseStart > 0 ? timing.responseStart - navigationStart : undefined }; } /** * Setup Core Web Vitals observers */ private setupCoreWebVitalsObservers(): void { // Largest Contentful Paint (LCP) this.observeMetric('largest-contentful-paint', (entries) => { const lastEntry = entries[entries.length - 1]; this.metrics.lcp = lastEntry.startTime; }); // First Input Delay (FID) this.observeMetric('first-input', (entries) => { const firstEntry = entries[0]; this.metrics.fid = (firstEntry as any).processingStart - firstEntry.startTime; }); // Cumulative Layout Shift (CLS) this.observeMetric('layout-shift', (entries) => { let clsValue = 0; entries.forEach(entry => { if (!(entry as any).hadRecentInput) { clsValue += (entry as any).value; } }); this.metrics.cls = clsValue; }); // First Contentful Paint (FCP) this.observeMetric('paint', (entries) => { entries.forEach(entry => { if (entry.name === 'first-contentful-paint') { this.metrics.fcp = entry.startTime; } }); }); } /** * Setup resource timing observer */ private setupResourceTimingObserver(): void { this.observeMetric('resource', (entries) => { const resources: ResourceTiming[] = []; let totalSize = 0; entries.forEach(entry => { const resource: ResourceTiming = { name: entry.name, type: this.getResourceType(entry.name), size: (entry as any).transferSize || 0, duration: entry.duration, startTime: entry.startTime }; resources.push(resource); totalSize += resource.size; }); this.metrics.resourceCount = (this.metrics.resourceCount || 0) + resources.length; this.metrics.totalResourceSize = (this.metrics.totalResourceSize || 0) + totalSize; // Track resource performance issues const slowResources = resources.filter(r => r.duration > 1000); if (slowResources.length > 0) { this.sdk.track('slow_resources', { count: slowResources.length, resources: slowResources.map(r => ({ name: r.name, type: r.type, duration: r.duration })) }); } }); } /** * Setup navigation timing observer */ private setupNavigationTimingObserver(): void { this.observeMetric('navigation', (entries) => { const entry = entries[0]; if (entry) { this.metrics.dnsLookup = (entry as any).domainLookupEnd - (entry as any).domainLookupStart; this.metrics.tcpConnect = (entry as any).connectEnd - (entry as any).connectStart; this.metrics.ttfb = (entry as any).responseStart - entry.startTime; } }); } /** * Setup user timing observer */ private setupUserTimingObserver(): void { this.observeMetric('measure', (entries) => { if (!this.metrics.customMeasures) { this.metrics.customMeasures = {}; } entries.forEach(entry => { this.metrics.customMeasures![entry.name] = entry.duration; }); }); this.observeMetric('mark', (entries) => { if (!this.metrics.customMarks) { this.metrics.customMarks = {}; } entries.forEach(entry => { this.metrics.customMarks![entry.name] = entry.startTime; }); }); } /** * Generic metric observer setup */ private observeMetric(entryType: string, callback: (entries: PerformanceEntry[]) => void): void { try { const observer = new PerformanceObserver((list) => { callback(list.getEntries()); }); observer.observe({ entryTypes: [entryType] }); this.observers.add(observer); } catch (error) { if (this.config.debug) { console.warn(`TinyTapAnalytics: Failed to observe ${entryType}`, error); } } } /** * Schedule periodic metrics collection */ private scheduleMetricsCollection(): void { // Collect domContentLoaded timing when event fires (or immediately if already fired) if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { this.collectDOMContentLoadedMetric(); }); } else { // Page already loaded or interactive, collect immediately this.collectDOMContentLoadedMetric(); } // Collect metrics on page load (or immediately if already loaded) if (document.readyState === 'complete') { // Page already fully loaded this.collectLoadCompleteMetric(); setTimeout(() => this.sendPerformanceData(), 1000); } else { window.addEventListener('load', () => { this.collectLoadCompleteMetric(); // Send performance data after a short delay to ensure all metrics are collected setTimeout(() => this.sendPerformanceData(), 1000); }); } // Collect metrics on page unload window.addEventListener('beforeunload', () => { this.sendPerformanceData(); }); // Collect metrics when page becomes hidden document.addEventListener('visibilitychange', () => { if (document.hidden) { this.sendPerformanceData(); } }); } /** * Collect DOMContentLoaded metric */ private collectDOMContentLoadedMetric(): void { if (window.performance && window.performance.timing) { const timing = window.performance.timing; const navigationStart = timing.navigationStart; if (timing.domContentLoadedEventEnd > 0) { this.metrics.domContentLoaded = timing.domContentLoadedEventEnd - navigationStart; } } } /** * Collect load complete metric */ private collectLoadCompleteMetric(): void { if (window.performance && window.performance.timing) { const timing = window.performance.timing; const navigationStart = timing.navigationStart; if (timing.loadEventEnd > 0) { this.metrics.loadComplete = timing.loadEventEnd - navigationStart; } } } /** * Send performance data to API */ private sendPerformanceData(): void { const performanceData = { metrics: this.metrics, url: window.location.href, userAgent: navigator.userAgent, timestamp: Date.now(), viewport: { width: window.innerWidth, height: window.innerHeight }, connection: this.getConnectionInfo() }; this.sdk.track('performance_metrics', performanceData); // Check for performance issues and send alerts this.checkPerformanceThresholds(); if (this.config.debug) { console.log('TinyTapAnalytics: Performance data sent', this.metrics); } } /** * Check performance thresholds and send alerts */ private checkPerformanceThresholds(): void { const issues: string[] = []; // Core Web Vitals thresholds if (this.metrics.lcp && this.metrics.lcp > 2500) { issues.push('LCP > 2.5s'); } if (this.metrics.fid && this.metrics.fid > 100) { issues.push('FID > 100ms'); } if (this.metrics.cls && this.metrics.cls > 0.1) { issues.push('CLS > 0.1'); } if (this.metrics.fcp && this.metrics.fcp > 1800) { issues.push('FCP > 1.8s'); } if (this.metrics.ttfb && this.metrics.ttfb > 600) { issues.push('TTFB > 600ms'); } if (issues.length > 0) { this.sdk.track('performance_issues', { issues, metrics: this.metrics, severity: issues.length > 2 ? 'high' : 'medium' }); } } /** * Add custom performance mark */ public mark(name: string): void { if (!this.isActive || !window.performance || !window.performance.mark) { return; } try { window.performance.mark(name); if (this.config.debug) { console.log(`TinyTapAnalytics: Performance mark '${name}' added`); } } catch (error) { if (this.config.debug) { console.warn(`TinyTapAnalytics: Failed to add mark '${name}'`, error); } } } /** * Add custom performance measure */ public measure(name: string, startMark?: string, endMark?: string): void { if (!this.isActive || !window.performance || !window.performance.measure) { return; } try { if (startMark && endMark) { window.performance.measure(name, startMark, endMark); } else if (startMark) { window.performance.measure(name, startMark); } else { window.performance.measure(name); } if (this.config.debug) { console.log(`TinyTapAnalytics: Performance measure '${name}' added`); } } catch (error) { if (this.config.debug) { console.warn(`TinyTapAnalytics: Failed to add measure '${name}'`, error); } } } /** * Get current performance metrics */ public getMetrics(): PerformanceMetrics { return { ...this.metrics }; } /** * Get Core Web Vitals score */ public getCoreWebVitalsScore(): { lcp: 'good' | 'needs-improvement' | 'poor' | 'unknown'; fid: 'good' | 'needs-improvement' | 'poor' | 'unknown'; cls: 'good' | 'needs-improvement' | 'poor' | 'unknown'; overall: 'good' | 'needs-improvement' | 'poor'; } { const lcpScore = this.metrics.lcp ? this.metrics.lcp <= 2500 ? 'good' : this.metrics.lcp <= 4000 ? 'needs-improvement' : 'poor' : 'unknown'; const fidScore = this.metrics.fid ? this.metrics.fid <= 100 ? 'good' : this.metrics.fid <= 300 ? 'needs-improvement' : 'poor' : 'unknown'; const clsScore = this.metrics.cls ? this.metrics.cls <= 0.1 ? 'good' : this.metrics.cls <= 0.25 ? 'needs-improvement' : 'poor' : 'unknown'; const scores = [lcpScore, fidScore, clsScore].filter(score => score !== 'unknown'); const goodCount = scores.filter(score => score === 'good').length; const poorCount = scores.filter(score => score === 'poor').length; let overall: 'good' | 'needs-improvement' | 'poor'; if (scores.length === 0) { overall = 'poor'; } else if (goodCount === scores.length) { overall = 'good'; } else if (poorCount > 0) { overall = 'poor'; } else { overall = 'needs-improvement'; } return { lcp: lcpScore, fid: fidScore, cls: clsScore, overall }; } /** * Get resource type from URL */ private getResourceType(url: string): string { const extension = url.split('.').pop()?.toLowerCase(); if (['js', 'mjs'].includes(extension || '')) { return 'script'; } if (['css'].includes(extension || '')) { return 'stylesheet'; } if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension || '')) { return 'image'; } if (['woff', 'woff2', 'ttf', 'otf'].includes(extension || '')) { return 'font'; } if (['mp4', 'webm', 'ogg'].includes(extension || '')) { return 'video'; } if (['mp3', 'wav', 'ogg'].includes(extension || '')) { return 'audio'; } if (url.includes('/api/') || url.includes('.json')) { return 'xhr'; } return 'other'; } /** * Get connection information */ private getConnectionInfo(): any { const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection; if (!connection) { return null; } return { effectiveType: connection.effectiveType, downlink: connection.downlink, rtt: connection.rtt, saveData: connection.saveData }; } /** * Check if Performance API is available */ private isPerformanceAPIAvailable(): boolean { return typeof window !== 'undefined' && 'performance' in window && 'PerformanceObserver' in window; } /** * Clear all performance data */ public clearData(): void { this.metrics = {}; if (this.config.debug) { console.log('TinyTapAnalytics: Performance data cleared'); } } }