analytics/packages/analytics-client/src/analytics-client.ts
2026-04-04 15:14:01 -07:00

267 lines
7.6 KiB
TypeScript

import { BatchQueue } from './batch-queue';
import { getDeviceData, type CollectedDeviceData } from './device-collector';
import { captureAttribution, type StoredAttribution } from './utm-extractor';
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;
export class AnalyticsClient {
private config: Required<AnalyticsConfig>;
private queue: BatchQueue | null = null;
private sessionId: string;
private interactionQueue: InteractionEventPayload[] = [];
private interactionFlushTimer: ReturnType<typeof setInterval> | null = null;
private deviceData: CollectedDeviceData | null = null;
private attribution: StoredAttribution | null = null;
constructor(config: AnalyticsConfig) {
this.config = {
batchSize: 10,
batchInterval: 5000,
enableDebugLogging: false,
enabled: true,
scrollTracking: { enabled: false },
trackResizes: false,
resizeDebounceMs: 1000,
writeKey: undefined,
...config,
};
// Skip initialization if analytics is disabled
if (!this.config.enabled) {
this.sessionId = '';
if (this.config.enableDebugLogging) {
console.log('[Analytics] 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<ViewEventData, 'app' | 'sessionId'>): 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,
// 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<void> {
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<void> {
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 }),
});
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 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<void> {
const viewEvents = events
.filter(this.isViewEvent)
.map((e) => e.data);
const engagementEvents = events
.filter(this.isEngagementEvent)
.map((e) => e.data);
const promises: Promise<void>[] = [];
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<void> {
const headers: Record<string, string> = { '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<void> {
const headers: Record<string, string> = { '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)}`;
}
}