import { BatchQueue } from './batch-queue'; import { getDeviceData, type CollectedDeviceData } from './device-collector'; import { captureAttribution, type StoredAttribution } from './utm-extractor'; import { CrossDomainLinker, type CrossDomainConfig } from './cross-domain'; import type { AnalyticsConfig, AttributionData, BatchedEvent, ViewEventData, EngagementEventData, InteractionEvent, InteractionEventPayload, } from './types'; // Type declarations for browser APIs (allows compilation in Node.js environments) declare const window: { addEventListener: (event: string, handler: () => void) => void; location: { hostname: string; href: string }; } | undefined; declare const localStorage: { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void } | undefined; export class AnalyticsClient { private config: Required; private queue: BatchQueue; private sessionId: string; private interactionQueue: InteractionEventPayload[] = []; private interactionFlushTimer: ReturnType | null = null; private deviceData: CollectedDeviceData | null = null; private attribution: StoredAttribution | null = null; private crossDomainLinker: CrossDomainLinker | null = null; constructor(config: AnalyticsConfig) { this.config = { batchSize: 10, batchInterval: 5000, enableDebugLogging: false, sessionIdKey: 'analytics_session_id', enabled: true, scrollTracking: { enabled: false }, trackResizes: false, resizeDebounceMs: 1000, crossDomainEnabled: false, allowedDomains: [], currentDomain: typeof window !== 'undefined' ? window.location.hostname : '', ...config, }; // Skip initialization if analytics is disabled if (!this.config.enabled) { this.sessionId = ''; this.queue = null as unknown as BatchQueue; if (this.config.enableDebugLogging) { console.log('[Analytics] Disabled via config'); } return; } this.sessionId = this.getOrCreateSessionId(); this.deviceData = getDeviceData(); // Collect device data once per session this.attribution = captureAttribution(); // Capture UTM params on first load this.queue = new BatchQueue( this.config.batchSize, this.config.batchInterval, this.flushBatch.bind(this), this.config.enableDebugLogging, ); // Initialize cross-domain linker if enabled if (this.config.crossDomainEnabled) { this.initCrossDomain(); } // Flush on page unload if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { this.flush(); }); } // Set up periodic interaction flush this.interactionFlushTimer = setInterval(() => { this.flushInteractions(); }, this.config.batchInterval); } /** * Initialize cross-domain session linking. */ private async initCrossDomain(): Promise { const crossDomainConfig: CrossDomainConfig = { apiBaseUrl: this.config.apiBaseUrl, sessionId: this.sessionId, domain: this.config.currentDomain, allowedDomains: this.config.allowedDomains, enableDebugLogging: this.config.enableDebugLogging, }; this.crossDomainLinker = new CrossDomainLinker(crossDomainConfig); // Check for pending cross-domain token const pendingToken = CrossDomainLinker.getPendingToken(); if (pendingToken) { if (this.config.enableDebugLogging) { console.log('[Analytics] Found cross-domain token, adopting session'); } await this.crossDomainLinker.adoptSession(pendingToken, this.sessionId); } } /** * Get cross-domain linker for generating tokens for outbound links. */ getCrossDomainLinker(): CrossDomainLinker | null { return this.crossDomainLinker; } /** * Generate a cross-domain URL with token appended. * Returns original URL if cross-domain is not enabled. */ async getCrossDomainUrl(targetUrl: string): Promise { if (!this.crossDomainLinker) { return targetUrl; } return this.crossDomainLinker.appendTokenToUrl(targetUrl); } /** * Get current attribution data. */ getAttribution(): AttributionData | null { if (!this.attribution) { return null; } return { utmSource: this.attribution.utmSource, utmMedium: this.attribution.utmMedium, utmCampaign: this.attribution.utmCampaign, utmContent: this.attribution.utmContent, utmTerm: this.attribution.utmTerm, }; } trackView(data: Omit): void { if (!this.config.enabled) {return;} // Include attribution on first view (or all views for completeness) const attribution = this.getAttribution(); const event: BatchedEvent = { type: 'view', data: { ...data, app: this.config.appName, sessionId: this.sessionId, // Include client device data for server-side enrichment clientDevice: this.deviceData ?? undefined, // Include attribution data for first-touch tracking attribution: attribution ?? undefined, } as ViewEventData, timestamp: Date.now(), }; this.queue.add(event); } trackEngagement(data: EngagementEventData): void { if (!this.config.enabled) {return;} const event: BatchedEvent = { type: 'engagement', data, timestamp: Date.now(), }; this.queue.add(event); } trackInteraction(event: InteractionEvent): void { if (!this.config.enabled) {return;} const payload: InteractionEventPayload = { type: event.type, data: event.data, sessionId: this.sessionId, timestamp: Date.now(), }; this.interactionQueue.push(payload); // Flush if batch size reached if (this.interactionQueue.length >= this.config.batchSize) { this.flushInteractions(); } } async flush(): Promise { if (!this.config.enabled) {return;} await Promise.all([ this.queue.flush(), this.flushInteractions(), ]); } destroy(): void { if (!this.config.enabled) {return;} this.queue.destroy(); if (this.interactionFlushTimer) { clearInterval(this.interactionFlushTimer); this.interactionFlushTimer = null; } } private async flushInteractions(): Promise { if (this.interactionQueue.length === 0) {return;} const events = [...this.interactionQueue]; this.interactionQueue = []; try { await fetch(`${this.config.apiBaseUrl}/analytics/track/interaction`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ events }), }); if (this.config.enableDebugLogging) { console.log(`[Analytics] Flushed ${events.length} interaction events`); } } catch (error) { // Re-queue on failure this.interactionQueue = [...events, ...this.interactionQueue]; if (this.config.enableDebugLogging) { console.error('[Analytics] Failed to flush interactions:', error); } } } private async flushBatch(events: BatchedEvent[]): Promise { const viewEvents = events .filter((e) => e.type === 'view') .map((e) => e.data as ViewEventData); const engagementEvents = events .filter((e) => e.type === 'engagement') .map((e) => e.data as EngagementEventData); const promises: Promise[] = []; if (viewEvents.length > 0) { promises.push(this.sendViewEvents(viewEvents)); } if (engagementEvents.length > 0) { promises.push(this.sendEngagementEvents(engagementEvents)); } await Promise.all(promises); } private async sendViewEvents(events: ViewEventData[]): Promise { for (const event of events) { await fetch(`${this.config.apiBaseUrl}/analytics/track/view`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(event), }); } } private async sendEngagementEvents( events: EngagementEventData[], ): Promise { for (const event of events) { await fetch(`${this.config.apiBaseUrl}/analytics/track/engagement`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(event), }); } } private getOrCreateSessionId(): string { if (typeof window === 'undefined' || typeof localStorage === 'undefined') { return this.generateSessionId(); } const stored = localStorage.getItem(this.config.sessionIdKey); if (stored) { return stored; } const newSessionId = this.generateSessionId(); localStorage.setItem(this.config.sessionIdKey, newSessionId); return newSessionId; } private generateSessionId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; } }