feat(analytics-client): disable analytics in dev mode by default

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>
This commit is contained in:
Quinn Ftw 2025-12-26 10:10:48 -08:00
parent 8080b31929
commit 9bd0813bab
3 changed files with 325 additions and 0 deletions

View file

@ -0,0 +1,159 @@
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)}`;
}
}

View file

@ -0,0 +1,43 @@
export interface ViewEventData {
contentId: string;
contentType: 'video' | 'image' | 'post' | 'stream' | 'product' | 'page';
userId?: string;
sessionId: string;
referrer?: string;
deviceType?: 'mobile' | 'tablet' | 'desktop';
app: string;
domain?: string;
duration?: number;
ipAddress?: string;
}
export interface EngagementEventData {
userId: string;
metricType: 'like' | 'comment' | 'share' | 'subscribe' | 'tip' | 'purchase';
targetId: string;
targetType: 'content' | 'user' | 'product' | 'stream';
metadata?: Record<string, unknown>;
}
export interface AnalyticsConfig {
apiBaseUrl: string;
appName: string;
batchSize?: number;
batchInterval?: number;
enableDebugLogging?: boolean;
sessionIdKey?: string;
/** When false, all tracking methods become no-ops. Useful for dev mode. */
enabled?: boolean;
}
export interface BatchedEvent {
type: 'view' | 'engagement';
data: ViewEventData | EngagementEventData;
timestamp: number;
}
export interface AnalyticsContext {
trackView: (data: Omit<ViewEventData, 'app' | 'sessionId'>) => void;
trackEngagement: (data: EngagementEventData) => void;
flush: () => Promise<void>;
}

View file

@ -0,0 +1,123 @@
import { AnalyticsProvider } from '@lilith/analytics-client/react'
import { I18nProvider } from '@lilith/i18n'
import { ThemeProvider } from '@lilith/theme-provider'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ErrorBoundary } from 'react-error-boundary'
import { Toaster } from 'react-hot-toast'
import App from './App'
import { bundledResources, useApiMode, LANDING_NAMESPACES } from './locales'
import './index.css'
// i18n Strategy:
// - Development (default): Use bundled JSON locales for instant loading (0ms)
// - API mode (VITE_I18N_USE_API=true): Use ML translation API for testing
// The bundled approach eliminates network dependency during development
// Create a QueryClient for React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
})
// Initialize MSW for i18n API mocking in development
// Note: MSW is started asynchronously without blocking render.
// If MSW fails to start, the app continues without mocks.
function initMSW() {
if (import.meta.env.DEV) {
import('./mocks/browser')
.then(({ startMockServiceWorker }) => startMockServiceWorker())
.then(() => console.log('[MSW] Mock Service Worker started for i18n API'))
.catch((error) => console.warn('[MSW] Failed to start (app continues without mocks):', error))
}
}
// Register service worker for translation caching
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/i18n-sw.js')
.then((registration) => {
console.log('[i18n] Service worker registered:', registration.scope)
})
.catch((error) => {
console.warn('[i18n] Service worker registration failed:', error)
})
})
}
// Note: ErrorFallback cannot use i18n because it renders outside the I18nProvider
// when the error boundary catches errors during provider initialization.
// This is an acceptable exception - error boundaries need hardcoded fallback text.
function ErrorFallback({ error }: { error: Error }) {
return (
<div role="alert" style={{ padding: '2rem', textAlign: 'center' }}>
<h1>Something went wrong</h1>
<pre style={{ color: 'red' }}>{error.message}</pre>
</div>
)
}
// Environment-based analytics configuration
// In dev: disabled by default (no CORS errors), enable with VITE_ANALYTICS_ENABLED=true
// In prod: enabled by default, disable with VITE_ANALYTICS_ENABLED=false
const analyticsConfig = {
apiBaseUrl: import.meta.env.VITE_ANALYTICS_API_URL || 'http://localhost:4000',
appName: 'landing',
batchSize: import.meta.env.PROD ? 10 : 1,
batchInterval: import.meta.env.PROD ? 5000 : 1000,
enabled: import.meta.env.PROD
? import.meta.env.VITE_ANALYTICS_ENABLED !== 'false'
: import.meta.env.VITE_ANALYTICS_ENABLED === 'true',
enableDebugLogging: import.meta.env.DEV,
}
// i18n configuration
// API URL only used when VITE_I18N_USE_API=true
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
// Build i18n config based on mode
const i18nConfig = {
// Use bundled resources by default (instant loading, no network)
// Only use API when explicitly enabled via VITE_I18N_USE_API=true
...(useApiMode ? { apiUrl: `${API_URL}/translations` } : { resources: bundledResources }),
defaultLanguage: 'en',
supportedLanguages: ['en', 'es'],
namespaces: [...LANDING_NAMESPACES],
defaultNamespace: 'common',
debug: import.meta.env.DEV,
// Disable ML fallback when using bundled resources (not needed)
enableMLFallback: useApiMode,
}
// Render app - MSW starts in background without blocking
function renderApp() {
// Start MSW in background (non-blocking)
initMSW()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<QueryClientProvider client={queryClient}>
<AnalyticsProvider config={analyticsConfig}>
<I18nProvider config={i18nConfig}>
<ThemeProvider>
{/* App now contains the new I18nProvider from makeI18n factory */}
<App />
<Toaster position="top-center" />
</ThemeProvider>
</I18nProvider>
</AnalyticsProvider>
</QueryClientProvider>
</ErrorBoundary>
</React.StrictMode>
)
}
renderApp()