@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
381 lines (328 loc) • 10.2 kB
text/typescript
/**
* Error Handler for TinyTapAnalytics SDK
* Provides graceful error handling that never breaks host websites
*/
import { TinyTapAnalyticsConfig, SDKError } from '../types/index';
import packageJson from '../../package.json';
export class ErrorHandler {
private config: TinyTapAnalyticsConfig;
private errorCount = 0;
private maxErrors = 10;
private errorBuffer: SDKError[] = [];
private reportingInterval: number | null = null;
private isShutdown = false;
private reportingIntervalMs: number;
constructor(config: TinyTapAnalyticsConfig, reportingIntervalMs = 30000) {
this.config = config;
this.reportingIntervalMs = reportingIntervalMs;
this.setupGlobalErrorHandling();
this.startErrorReporting();
}
/**
* Handle an error gracefully
*/
public handle(error: Error, context?: string, data?: any): void {
if (this.isShutdown) {
return;
}
try {
const sdkError = this.createSDKError(error, context, data);
// Always log to console in debug mode
if (this.config.debug) {
console.error('TinyTapAnalytics SDK Error:', sdkError);
}
// Track error count to prevent infinite loops
this.errorCount++;
if (this.errorCount > this.maxErrors) {
this.shutdown();
return;
}
// Buffer error for reporting
this.bufferError(sdkError);
// Handle specific error types
this.handleSpecificError(sdkError);
} catch (handlingError) {
// If error handling itself fails, shutdown gracefully
if (this.config.debug) {
console.error('TinyTapAnalytics: Error handler failed:', handlingError);
}
this.shutdown();
}
}
/**
* Report a warning (non-fatal error)
*/
public warn(message: string, context?: string, data?: any): void {
if (this.config.debug) {
console.warn('TinyTapAnalytics Warning:', message, { context, data });
}
const warning: SDKError = {
code: 'WARNING',
message,
context: context || 'general',
timestamp: Date.now(),
originalError: undefined,
stack: new Error().stack
};
this.bufferError(warning);
}
/**
* Get current error statistics
*/
public getStats(): { errorCount: number; isShutdown: boolean; bufferSize: number } {
return {
errorCount: this.errorCount,
isShutdown: this.isShutdown,
bufferSize: this.errorBuffer.length
};
}
/**
* Manually shutdown error handling
*/
public shutdown(): void {
if (this.isShutdown) {
return;
}
this.isShutdown = true;
if (this.reportingInterval) {
clearInterval(this.reportingInterval);
this.reportingInterval = null;
}
// Final error report
this.reportErrors();
if (this.config.debug) {
console.log('TinyTapAnalytics: Error handler shutdown due to excessive errors');
}
}
/**
* Create standardized SDK error
*/
private createSDKError(error: Error, context?: string, data?: any): SDKError {
return {
code: this.getErrorCode(error),
message: error.message || 'Unknown error',
context: context || 'unknown',
originalError: error,
timestamp: Date.now(),
stack: error.stack,
data
};
}
/**
* Get appropriate error code based on error type
*/
private getErrorCode(error: Error): string {
if (error.name === 'TypeError') {
return 'TYPE_ERROR';
}
if (error.name === 'ReferenceError') {
return 'REFERENCE_ERROR';
}
if (error.name === 'NetworkError') {
return 'NETWORK_ERROR';
}
if (error.message.includes('quota')) {
return 'STORAGE_QUOTA_EXCEEDED';
}
if (error.message.includes('timeout')) {
return 'TIMEOUT_ERROR';
}
if (error.message.includes('abort')) {
return 'REQUEST_ABORTED';
}
if (error.message.includes('CORS')) {
return 'CORS_ERROR';
}
if (error.message.includes('CSP')) {
return 'CSP_VIOLATION';
}
return 'GENERAL_ERROR';
}
/**
* Handle specific error types with appropriate actions
*/
private handleSpecificError(sdkError: SDKError): void {
switch (sdkError.code) {
case 'STORAGE_QUOTA_EXCEEDED':
this.handleStorageQuotaError();
break;
case 'NETWORK_ERROR':
case 'TIMEOUT_ERROR':
this.handleNetworkError(sdkError);
break;
case 'CSP_VIOLATION':
this.handleCSPViolation(sdkError);
break;
case 'CORS_ERROR':
this.handleCORSError(sdkError);
break;
}
}
/**
* Handle storage quota exceeded errors
*/
private handleStorageQuotaError(): void {
try {
// Clear old data to free up space
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('tinytapanalytics_') && key.includes('_old_')) {
localStorage.removeItem(key);
}
});
} catch (error) {
// If we can't clear storage, we'll have to operate without persistence
}
}
/**
* Handle network-related errors
*/
private handleNetworkError(sdkError: SDKError): void {
// Network errors are expected and shouldn't cause shutdown
// They're handled by the NetworkManager's retry logic
}
/**
* Handle Content Security Policy violations
*/
private handleCSPViolation(sdkError: SDKError): void {
if (this.config.debug) {
console.warn('TinyTapAnalytics: CSP violation detected. Some features may be limited.');
}
// Could trigger fallback mode for stricter CSP environments
}
/**
* Handle CORS errors
*/
private handleCORSError(sdkError: SDKError): void {
if (this.config.debug) {
console.warn('TinyTapAnalytics: CORS error detected. Check domain configuration.');
}
}
/**
* Buffer error for later reporting
*/
private bufferError(error: SDKError): void {
this.errorBuffer.push(error);
// Keep buffer size manageable
if (this.errorBuffer.length > 50) {
this.errorBuffer = this.errorBuffer.slice(-25); // Keep last 25 errors
}
}
/**
* Set up global error handling for SDK-related errors
*/
private setupGlobalErrorHandling(): void {
// Only catch errors that are clearly from our SDK
const originalErrorHandler = window.onerror;
window.onerror = (message, source, lineno, colno, error) => {
// Check if error is from our SDK
if (this.isSDKError(error, source, message)) {
this.handle(error || new Error(String(message)), 'global_error');
}
// Call original error handler
if (originalErrorHandler) {
return originalErrorHandler.call(window, message, source, lineno, colno, error);
}
return false; // Don't prevent default browser error handling
};
// Handle unhandled promise rejections from SDK
const originalRejectionHandler = window.onunhandledrejection;
window.onunhandledrejection = (event) => {
if (this.isSDKError(event.reason)) {
this.handle(
event.reason instanceof Error ? event.reason : new Error(String(event.reason)),
'unhandled_promise_rejection'
);
}
// Call original rejection handler
if (originalRejectionHandler) {
return originalRejectionHandler.call(window, event);
}
return false;
};
}
/**
* Check if an error is from our SDK
*/
private isSDKError(error: any, source?: string, message?: string | Event): boolean {
// Check stack trace for SDK references
if (error && error.stack) {
if (error.stack.includes('TinyTapAnalytics') ||
error.stack.includes('tinytapanalytics') ||
error.stack.includes('tracker.js')) {
return true;
}
}
// Check source URL
if (source && (source.includes('tinytapanalytics') || source.includes('tracker.js'))) {
return true;
}
// Check error message
if (message && typeof message === 'string' && message.includes('TinyTapAnalytics')) {
return true;
}
return false;
}
/**
* Start periodic error reporting
*/
private startErrorReporting(): void {
// Report errors periodically
this.reportingInterval = window.setInterval(() => {
this.reportErrors();
}, this.reportingIntervalMs);
}
/**
* Report buffered errors to the API
*/
private reportErrors(): void {
if (this.errorBuffer.length === 0 || this.isShutdown) {
return;
}
try {
const errors = [...this.errorBuffer];
this.errorBuffer = [];
// Send errors to API (don't use NetworkManager to avoid circular dependencies)
const payload = {
sdk_version: packageJson.version,
website_id: this.config.websiteId,
timestamp: new Date().toISOString(),
errors: errors.map(error => ({
code: error.code,
message: error.message,
context: error.context,
timestamp: error.timestamp,
stack: error.stack ? error.stack.substring(0, 1000) : undefined, // Limit stack trace size
data: error.data
}))
};
// Use sendBeacon if available for reliability
if (navigator.sendBeacon) {
const url = `${this.config.endpoint}/api/v1/errors`;
navigator.sendBeacon(url, JSON.stringify(payload));
} else {
// Fallback to fetch with no await to avoid blocking
fetch(`${this.config.endpoint}/api/v1/errors`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).catch(() => {
// Silently fail error reporting
});
}
} catch (reportingError) {
// If error reporting fails, we can't do much except log
if (this.config.debug) {
console.error('TinyTapAnalytics: Failed to report errors:', reportingError);
}
}
}
/**
* Clean up resources
*/
public destroy(): void {
this.shutdown();
// Clean up global error handlers if needed
// Note: We don't restore original handlers as other scripts might depend on them
}
}