Analytics was causing CORS errors in dev when no analytics server was running. Now analytics is disabled by default in dev mode and enabled in production. Can be overridden via VITE_ANALYTICS_ENABLED env var. - Add `enabled` config option to AnalyticsConfig type - AnalyticsClient no-ops all methods when disabled - Dev: disabled by default, enable with VITE_ANALYTICS_ENABLED=true - Prod: enabled by default, disable with VITE_ANALYTICS_ENABLED=false 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
159 lines
4.1 KiB
TypeScript
159 lines
4.1 KiB
TypeScript
import { BatchQueue } from './batch-queue';
|
|
import type {
|
|
AnalyticsConfig,
|
|
BatchedEvent,
|
|
ViewEventData,
|
|
EngagementEventData,
|
|
} from './types';
|
|
|
|
// Type declarations for browser APIs (allows compilation in Node.js environments)
|
|
declare const window: { addEventListener: (event: string, handler: () => void) => void } | undefined;
|
|
declare const localStorage: { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void } | undefined;
|
|
|
|
export class AnalyticsClient {
|
|
private config: Required<AnalyticsConfig>;
|
|
private queue: BatchQueue;
|
|
private sessionId: string;
|
|
|
|
constructor(config: AnalyticsConfig) {
|
|
this.config = {
|
|
batchSize: 10,
|
|
batchInterval: 5000,
|
|
enableDebugLogging: false,
|
|
sessionIdKey: 'analytics_session_id',
|
|
enabled: true,
|
|
...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.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();
|
|
});
|
|
}
|
|
}
|
|
|
|
trackView(data: Omit<ViewEventData, 'app' | 'sessionId'>): void {
|
|
if (!this.config.enabled) return;
|
|
|
|
const event: BatchedEvent = {
|
|
type: 'view',
|
|
data: {
|
|
...data,
|
|
app: this.config.appName,
|
|
sessionId: this.sessionId,
|
|
},
|
|
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);
|
|
}
|
|
|
|
async flush(): Promise<void> {
|
|
if (!this.config.enabled) return;
|
|
await this.queue.flush();
|
|
}
|
|
|
|
destroy(): void {
|
|
if (!this.config.enabled) return;
|
|
this.queue.destroy();
|
|
}
|
|
|
|
private async flushBatch(events: BatchedEvent[]): Promise<void> {
|
|
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<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> {
|
|
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<void> {
|
|
for (const event of events) {
|
|
await fetch(`${this.config.apiBaseUrl}/analytics/track/engagement`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(event),
|
|
credentials: 'include',
|
|
});
|
|
}
|
|
}
|
|
|
|
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)}`;
|
|
}
|
|
}
|