@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
352 lines (304 loc) • 9.85 kB
text/typescript
/**
* Performance Monitor for TinyTapAnalytics SDK
* Tracks Core Web Vitals and SDK performance metrics
*/
import { TinyTapAnalyticsConfig } from '../types/index';
interface PerformanceMetric {
name: string;
value: number;
timestamp: number;
rating: 'good' | 'needs-improvement' | 'poor';
}
interface WebVitalsData {
CLS?: PerformanceMetric;
FID?: PerformanceMetric;
FCP?: PerformanceMetric;
LCP?: PerformanceMetric;
TTFB?: PerformanceMetric;
}
export class PerformanceMonitor {
private config: TinyTapAnalyticsConfig;
private observers: PerformanceObserver[] = [];
private vitals: WebVitalsData = {};
private sdkMetrics: Map<string, number> = new Map();
private isSupported: boolean;
constructor(config: TinyTapAnalyticsConfig) {
this.config = config;
this.isSupported = 'PerformanceObserver' in window;
}
/**
* Initialize performance monitoring
*/
public init(): void {
if (!this.isSupported) {
if (this.config.debug) {
console.warn('TinyTapAnalytics: PerformanceObserver not supported');
}
return;
}
this.measureWebVitals();
this.measureSDKPerformance();
this.setupNavigationTiming();
}
/**
* Measure Core Web Vitals
*/
private measureWebVitals(): void {
// Largest Contentful Paint (LCP)
this.observeMetric('largest-contentful-paint', (entries) => {
const lastEntry = entries[entries.length - 1] as PerformanceEntry & { renderTime?: number; loadTime?: number };
const value = lastEntry.renderTime || lastEntry.loadTime || 0;
this.vitals.LCP = {
name: 'LCP',
value,
timestamp: Date.now(),
rating: this.getRating('LCP', value)
};
});
// First Input Delay (FID)
this.observeMetric('first-input', (entries) => {
const firstInput = entries[0] as PerformanceEntry & { processingStart?: number };
const value = firstInput.processingStart ? firstInput.processingStart - firstInput.startTime : 0;
this.vitals.FID = {
name: 'FID',
value,
timestamp: Date.now(),
rating: this.getRating('FID', value)
};
});
// First Contentful Paint (FCP)
this.observeMetric('paint', (entries) => {
for (const entry of entries) {
if (entry.name === 'first-contentful-paint') {
this.vitals.FCP = {
name: 'FCP',
value: entry.startTime,
timestamp: Date.now(),
rating: this.getRating('FCP', entry.startTime)
};
}
}
});
// Cumulative Layout Shift (CLS)
this.observeMetric('layout-shift', (entries) => {
let clsValue = 0;
for (const entry of entries) {
const layoutShift = entry as PerformanceEntry & { value?: number; hadRecentInput?: boolean };
if (!layoutShift.hadRecentInput) {
clsValue += layoutShift.value || 0;
}
}
this.vitals.CLS = {
name: 'CLS',
value: clsValue,
timestamp: Date.now(),
rating: this.getRating('CLS', clsValue)
};
}, { buffered: true });
}
/**
* Measure SDK-specific performance metrics
*/
private measureSDKPerformance(): void {
// Track bundle parsing time
const bundleStartTime = performance.now();
this.sdkMetrics.set('bundle_parse_start', bundleStartTime);
// Track initialization time
this.measureAsync('sdk_init', async () => {
// This will be called from the main SDK initialization
});
// Track event processing times
this.measureSync('event_processing', () => {
// This will be called during event processing
});
// Monitor memory usage if available
if ('memory' in performance) {
const memoryInfo = (performance as any).memory;
this.sdkMetrics.set('memory_used', memoryInfo.usedJSHeapSize);
this.sdkMetrics.set('memory_total', memoryInfo.totalJSHeapSize);
this.sdkMetrics.set('memory_limit', memoryInfo.jsHeapSizeLimit);
}
}
/**
* Setup navigation timing measurements
*/
private setupNavigationTiming(): void {
this.observeMetric('navigation', (entries) => {
const navEntry = entries[0] as PerformanceNavigationTiming;
// Time to First Byte (TTFB)
const ttfb = navEntry.responseStart - navEntry.requestStart;
this.vitals.TTFB = {
name: 'TTFB',
value: ttfb,
timestamp: Date.now(),
rating: this.getRating('TTFB', ttfb)
};
// Track other navigation metrics
this.sdkMetrics.set('dns_lookup', navEntry.domainLookupEnd - navEntry.domainLookupStart);
this.sdkMetrics.set('tcp_connect', navEntry.connectEnd - navEntry.connectStart);
this.sdkMetrics.set('request_response', navEntry.responseEnd - navEntry.requestStart);
this.sdkMetrics.set('dom_processing', navEntry.domContentLoadedEventEnd - navEntry.responseEnd);
});
}
/**
* Observe performance metrics
*/
private observeMetric(
entryType: string,
callback: (entries: PerformanceEntry[]) => void,
options: PerformanceObserverInit = {}
): void {
try {
const observer = new PerformanceObserver((list) => {
callback(list.getEntries());
});
observer.observe({
entryTypes: [entryType],
...options
});
this.observers.push(observer);
} catch (error) {
if (this.config.debug) {
console.warn(`TinyTapAnalytics: Failed to observe ${entryType}:`, error);
}
}
}
/**
* Measure synchronous operation performance
*/
public measureSync<T>(name: string, fn: () => T): T {
const start = performance.now();
const result = fn();
const duration = performance.now() - start;
this.sdkMetrics.set(name, duration);
if (this.config.debug && duration > 5) {
console.warn(`TinyTapAnalytics: Slow operation ${name}: ${duration.toFixed(2)}ms`);
}
return result;
}
/**
* Measure asynchronous operation performance
*/
public async measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
const result = await fn();
const duration = performance.now() - start;
this.sdkMetrics.set(name, duration);
if (this.config.debug && duration > 10) {
console.warn(`TinyTapAnalytics: Slow async operation ${name}: ${duration.toFixed(2)}ms`);
}
return result;
}
/**
* Get performance rating based on metric thresholds
*/
private getRating(metric: string, value: number): 'good' | 'needs-improvement' | 'poor' {
const thresholds = {
LCP: [2500, 4000], // Good: ≤2.5s, Poor: >4s
FID: [100, 300], // Good: ≤100ms, Poor: >300ms
FCP: [1800, 3000], // Good: ≤1.8s, Poor: >3s
CLS: [0.1, 0.25], // Good: ≤0.1, Poor: >0.25
TTFB: [800, 1800] // Good: ≤800ms, Poor: >1.8s
};
const [good, poor] = thresholds[metric as keyof typeof thresholds] || [0, 0];
if (value <= good) {
return 'good';
}
if (value <= poor) {
return 'needs-improvement';
}
return 'poor';
}
/**
* Get all Web Vitals data
*/
public getWebVitals(): WebVitalsData {
return { ...this.vitals };
}
/**
* Get SDK performance metrics
*/
public getSDKMetrics(): Record<string, number> {
return Object.fromEntries(this.sdkMetrics);
}
/**
* Get performance summary
*/
public getPerformanceSummary(): {
webVitals: WebVitalsData;
sdkMetrics: Record<string, number>;
overallRating: 'good' | 'needs-improvement' | 'poor';
} {
const webVitals = this.getWebVitals();
const sdkMetrics = this.getSDKMetrics();
// Calculate overall rating based on Core Web Vitals
const ratings = Object.values(webVitals).map(metric => metric.rating);
const poorCount = ratings.filter(r => r === 'poor').length;
const improvementCount = ratings.filter(r => r === 'needs-improvement').length;
let overallRating: 'good' | 'needs-improvement' | 'poor';
if (poorCount > 0) {
overallRating = 'poor';
} else if (improvementCount > 0) {
overallRating = 'needs-improvement';
} else {
overallRating = 'good';
}
return {
webVitals,
sdkMetrics,
overallRating
};
}
/**
* Report performance data to analytics endpoint
*/
public async reportPerformance(): Promise<void> {
if (!this.config.enablePerformanceMonitoring) {
return;
}
try {
const performanceData = this.getPerformanceSummary();
const payload = {
timestamp: new Date().toISOString(),
websiteId: this.config.websiteId,
userAgent: navigator.userAgent,
url: window.location.href,
...performanceData
};
// Use sendBeacon for reliability
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
navigator.sendBeacon(`${this.config.endpoint}/api/v1/performance`, blob);
} else {
// Fallback to fetch
fetch(`${this.config.endpoint}/api/v1/performance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true
}).catch(() => {
// Silently fail performance reporting
});
}
} catch (error) {
if (this.config.debug) {
console.warn('TinyTapAnalytics: Failed to report performance data:', error);
}
}
}
/**
* Clean up performance observers
*/
public destroy(): void {
this.observers.forEach(observer => {
try {
observer.disconnect();
} catch (error) {
// Ignore cleanup errors
}
});
this.observers = [];
this.vitals = {};
this.sdkMetrics.clear();
}
}