import { BatchQueue } from './batch-queue'; import { getDeviceData, type CollectedDeviceData } from './device-collector'; import { captureAttribution, type StoredAttribution } from './utm-extractor'; import { createLogger } from './logger'; 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; type ResolvedAnalyticsConfig = Required> & Pick; export class AnalyticsClient { private config: ResolvedAnalyticsConfig; private queue: BatchQueue | null = null; private sessionId: string; private interactionQueue: InteractionEventPayload[] = []; private interactionFlushTimer: ReturnType | null = null; private deviceData: CollectedDeviceData | null = null; private attribution: StoredAttribution | null = null; private log; constructor(config: AnalyticsConfig) { this.config = { batchSize: 10, batchInterval: 5000, enableDebugLogging: false, enabled: true, scrollTracking: { enabled: false }, trackResizes: false, resizeDebounceMs: 1000, writeKey: undefined, ...config, }; this.log = createLogger(this.config.enableDebugLogging); // Skip initialization if analytics is disabled if (!this.config.enabled) { this.sessionId = ''; this.log.debug('Disabled via config'); return; } // CONSENT-FREE: Generate ephemeral session ID (in-memory only) this.sessionId = this.generateSessionId(); 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, ); // 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); } /** * Ensure queue is initialized. Throws if analytics is disabled. */ private ensureQueue(): BatchQueue { if (!this.queue) { throw new Error('Analytics client not initialized (disabled or not configured)'); } return this.queue; } /** * 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(); // Construct ViewEventData with proper types const viewData: ViewEventData = { ...data, app: this.config.appName, sessionId: this.sessionId, // Original domain from ?via= redirect param, or current hostname for direct visits domain: this.attribution?.originalDomain ?? (typeof window !== 'undefined' ? window.location.hostname : undefined), // Include client device data for server-side enrichment clientDevice: this.deviceData ?? undefined, // Include attribution data for first-touch tracking attribution: attribution ?? undefined, }; const event: BatchedEvent = { type: 'view', data: viewData, timestamp: Date.now(), }; this.ensureQueue().add(event); } trackEngagement(data: EngagementEventData): void { if (!this.config.enabled) {return;} const event: BatchedEvent = { type: 'engagement', data, timestamp: Date.now(), }; this.ensureQueue().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.ensureQueue().flush(), this.flushInteractions(), ]); } destroy(): void { if (!this.config.enabled) {return;} this.ensureQueue().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', ...(this.config.writeKey ? { 'X-Write-Key': this.config.writeKey } : {}), }, body: JSON.stringify({ events }), }); this.log.debug(`Flushed ${events.length} interaction events`); } catch (error) { // Re-queue on failure this.interactionQueue = [...events, ...this.interactionQueue]; this.log.error('Failed to flush interactions:', error); } } private isViewEvent(event: BatchedEvent): event is BatchedEvent & { type: 'view'; data: ViewEventData } { return event.type === 'view'; } private isEngagementEvent(event: BatchedEvent): event is BatchedEvent & { type: 'engagement'; data: EngagementEventData } { return event.type === 'engagement'; } private async flushBatch(events: BatchedEvent[]): Promise { const viewEvents = events .filter(this.isViewEvent) .map((e) => e.data); const engagementEvents = events .filter(this.isEngagementEvent) .map((e) => e.data); 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 { const headers: Record = { 'Content-Type': 'application/json' }; if (this.config.writeKey) headers['X-Write-Key'] = this.config.writeKey; for (const event of events) { await fetch(`${this.config.apiBaseUrl}/analytics/track/view`, { method: 'POST', headers, body: JSON.stringify(event), }); } } private async sendEngagementEvents( events: EngagementEventData[], ): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (this.config.writeKey) headers['X-Write-Key'] = this.config.writeKey; for (const event of events) { await fetch(`${this.config.apiBaseUrl}/analytics/track/engagement`, { method: 'POST', headers, body: JSON.stringify(event), }); } } /** * Generate ephemeral session ID (consent-free, in-memory only). */ private generateSessionId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; } }