@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
text/typescript
/**
* 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');
}
}
}