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:
parent
8080b31929
commit
9bd0813bab
3 changed files with 325 additions and 0 deletions
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
43
@packages/@infrastructure/analytics-client/src/types.ts
Normal file
43
@packages/@infrastructure/analytics-client/src/types.ts
Normal 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>;
|
||||
}
|
||||
123
features/landing/frontend/src/main.tsx
Normal file
123
features/landing/frontend/src/main.tsx
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue