import type { AnalyticsAdapter, AnalyticsConfig, AnalyticsEvent, AliasPayload, EventMiddlewareFn, GroupPayload, IdentifyPayload, ScreenEvent, ScreenMiddlewareFn, UserTraits, } from '../types'; import { EventQueue } from './EventQueue'; import { SessionManager } from './SessionManager'; import { PrivacyManager } from './PrivacyManager'; import { generateId } from '../utils'; /** * Central analytics orchestrator. * Routes events through middleware → privacy filters → event queue → adapters. */ export class AnalyticsManager { private adapters: AnalyticsAdapter[] = []; private config: AnalyticsConfig; private queue: EventQueue; private sessionManager: SessionManager; private privacyManager: PrivacyManager; private eventMiddleware: EventMiddlewareFn[] = []; private screenMiddleware: ScreenMiddlewareFn[] = []; private anonymousId: string = generateId(); private userId?: string; private isInitialized = false; constructor(config: AnalyticsConfig) { this.config = config; this.adapters = config.adapters; this.privacyManager = new PrivacyManager(config.privacy); this.sessionManager = new SessionManager(config.session, (event) => { this.track(event.name, event.properties); }); this.queue = new EventQueue( async (items) => { await Promise.all( items.map(async (item) => { await Promise.all( this.adapters.map(async (adapter) => { try { if (item.type === 'track') { await adapter.track(item.event); } else { await adapter.screen(item.event); } } catch (err) { this.config.onError?.(err as Error, item.event); } }) ); }) ); }, config.queue ); } async initialize(): Promise { if (this.isInitialized) { return; } await Promise.all(this.adapters.map((a) => a.initialize())); this.queue.start(); this.sessionManager.start(); this.isInitialized = true; this.log('AnalyticsManager initialized with adapters:', this.adapters.map((a) => a.name)); } destroy(): void { this.queue.stop(); this.sessionManager.stop(); this.isInitialized = false; } // --------------------------------------------------------------------------- // Core tracking methods // --------------------------------------------------------------------------- track(eventName: string, properties?: Record): void { if (!this.isOptedIn()) return; const raw: AnalyticsEvent = { name: eventName, properties: { ...this.config.globalProperties, ...properties, }, timestamp: Date.now(), sessionId: this.sessionManager.getSessionId(), userId: this.userId, anonymousId: this.anonymousId, }; this.sessionManager.recordEvent(); this.runEventMiddleware(raw, (event) => { const sanitized = this.privacyManager.sanitizeEvent(event); this.queue.enqueue({ type: 'track', event: sanitized }); }); } screen(screenName: string, properties?: Record): void { if (!this.isOptedIn()) return; const raw: ScreenEvent = { name: screenName, properties: { ...this.config.globalProperties, ...properties, }, timestamp: Date.now(), sessionId: this.sessionManager.getSessionId(), userId: this.userId, anonymousId: this.anonymousId, previousScreen: this.sessionManager.getCurrentScreen(), }; this.sessionManager.recordScreen(screenName); this.runScreenMiddleware(raw, (event) => { const sanitized = this.privacyManager.sanitizeScreenEvent(event); this.queue.enqueue({ type: 'screen', event: sanitized }); }); } async identify(userId: string, traits?: UserTraits): Promise { if (!this.isOptedIn()) return; this.userId = userId; const payload: IdentifyPayload = { userId, traits: traits ? this.privacyManager.sanitizeTraits(traits) : undefined, timestamp: Date.now(), anonymousId: this.anonymousId, }; await Promise.all( this.adapters.map(async (adapter) => { try { await adapter.identify(payload); } catch (err) { this.log('identify error on adapter', adapter.name, err); } }) ); } async group(groupId: string, traits?: Record): Promise { if (!this.isOptedIn()) return; const payload: GroupPayload = { groupId, traits, timestamp: Date.now(), userId: this.userId, anonymousId: this.anonymousId, }; await Promise.all( this.adapters.map(async (adapter) => { try { await adapter.group?.(payload); } catch (err) { this.log('group error on adapter', adapter.name, err); } }) ); } async alias(newId: string, previousId?: string): Promise { if (!this.isOptedIn()) return; const payload: AliasPayload = { newId, previousId: previousId ?? this.anonymousId, timestamp: Date.now(), }; await Promise.all( this.adapters.map(async (adapter) => { try { await adapter.alias?.(payload); } catch (err) { this.log('alias error on adapter', adapter.name, err); } }) ); } async reset(): Promise { this.userId = undefined; this.anonymousId = generateId(); this.sessionManager.reset(); await Promise.all( this.adapters.map(async (adapter) => { try { await adapter.reset(); } catch (err) { this.log('reset error on adapter', adapter.name, err); } }) ); } async flush(): Promise { await this.queue.flush(); await Promise.all( this.adapters.map(async (adapter) => { try { await adapter.flush?.(); } catch (err) { this.log('flush error on adapter', adapter.name, err); } }) ); } // --------------------------------------------------------------------------- // Privacy controls // --------------------------------------------------------------------------- optOut(): void { this.privacyManager.optOut(); this.log('User opted out of analytics'); } optIn(): void { this.privacyManager.optIn(); this.log('User opted in to analytics'); } isOptedIn(): boolean { return this.privacyManager.isOptedIn(); } // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- addEventMiddleware(fn: EventMiddlewareFn): void { this.eventMiddleware.push(fn); } addScreenMiddleware(fn: ScreenMiddlewareFn): void { this.screenMiddleware.push(fn); } private runEventMiddleware( event: AnalyticsEvent, final: (event: AnalyticsEvent) => void ): void { const chain = [...this.eventMiddleware]; const run = (index: number, current: AnalyticsEvent) => { if (index >= chain.length) { final(current); return; } chain[index](current, (next) => run(index + 1, next)); }; run(0, event); } private runScreenMiddleware( event: ScreenEvent, final: (event: ScreenEvent) => void ): void { const chain = [...this.screenMiddleware]; const run = (index: number, current: ScreenEvent) => { if (index >= chain.length) { final(current); return; } chain[index](current, (next) => run(index + 1, next)); }; run(0, event); } // --------------------------------------------------------------------------- // Accessors // --------------------------------------------------------------------------- getAnonymousId(): string { return this.anonymousId; } getUserId(): string | undefined { return this.userId; } getSession() { return this.sessionManager.getSession(); } getQueueSize(): number { return this.queue.size(); } private log(...args: any[]): void { if (this.config.debug) { console.log('[AnalyticsBridge]', ...args); } } } // Singleton factory let instance: AnalyticsManager | null = null; export function createAnalytics(config: AnalyticsConfig): AnalyticsManager { instance = new AnalyticsManager(config); return instance; } export function getAnalytics(): AnalyticsManager { if (!instance) { throw new Error( '[AnalyticsBridge] Not initialized. Call createAnalytics() first.' ); } return instance; }