2025-12-26 10:10:48 -08:00
|
|
|
import { BatchQueue } from './batch-queue';
|
2025-12-30 01:35:47 -08:00
|
|
|
import { getDeviceData, type CollectedDeviceData } from './device-collector';
|
2026-01-03 12:24:11 -08:00
|
|
|
import { captureAttribution, type StoredAttribution } from './utm-extractor';
|
|
|
|
|
import { CrossDomainLinker, type CrossDomainConfig } from './cross-domain';
|
feat(eslint): integrate global DRY ESLint packages across @packages
- Configure 12 @packages to use global @eslint/config-base and @eslint/config-react
- Update ESLint config path syntax to use node_modules paths
- Add ESLint dependencies to React packages (messaging-hooks, react-query-utils,
websocket-client, analytics-client)
- Fix duplicate exports in @core/types (remove redundant re-exports)
- Auto-fix import order issues across all packages
- Add ESLint config for status-dashboard/server extending @eslint/config-base
- Migrate service-registry to @nestjs/bootstrap and @nestjs/health packages
- Integrate @nestjs/auth decorators (@Public, @CurrentUser) into auth system
- Fix FlexibleAuthGuard tests (add missing getAllAndOverride mock)
- Relax strict type-checking rules in base config for existing code
Packages configured:
- @infrastructure/api-client, service-discovery, websocket-client, analytics-client
- @testing/msw-handlers, mocks
- @utils/text-utils
- @core/types, design-tokens
- @utility/zname
- @hooks/messaging-hooks, react-query-utils
All packages now pass ESLint with 0 errors (warnings only).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:38:01 -08:00
|
|
|
|
2025-12-26 10:10:48 -08:00
|
|
|
import type {
|
|
|
|
|
AnalyticsConfig,
|
2026-01-03 12:24:11 -08:00
|
|
|
AttributionData,
|
2025-12-26 10:10:48 -08:00
|
|
|
BatchedEvent,
|
|
|
|
|
ViewEventData,
|
|
|
|
|
EngagementEventData,
|
2025-12-29 21:10:12 -08:00
|
|
|
InteractionEvent,
|
|
|
|
|
InteractionEventPayload,
|
2025-12-26 10:10:48 -08:00
|
|
|
} from './types';
|
|
|
|
|
|
|
|
|
|
// Type declarations for browser APIs (allows compilation in Node.js environments)
|
2026-01-03 12:24:11 -08:00
|
|
|
declare const window: {
|
|
|
|
|
addEventListener: (event: string, handler: () => void) => void;
|
|
|
|
|
location: { hostname: string; href: string };
|
|
|
|
|
} | undefined;
|
2025-12-26 10:10:48 -08:00
|
|
|
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;
|
2025-12-29 21:10:12 -08:00
|
|
|
private interactionQueue: InteractionEventPayload[] = [];
|
|
|
|
|
private interactionFlushTimer: ReturnType<typeof setInterval> | null = null;
|
2025-12-30 01:35:47 -08:00
|
|
|
private deviceData: CollectedDeviceData | null = null;
|
2026-01-03 12:24:11 -08:00
|
|
|
private attribution: StoredAttribution | null = null;
|
|
|
|
|
private crossDomainLinker: CrossDomainLinker | null = null;
|
2025-12-26 10:10:48 -08:00
|
|
|
|
|
|
|
|
constructor(config: AnalyticsConfig) {
|
|
|
|
|
this.config = {
|
|
|
|
|
batchSize: 10,
|
|
|
|
|
batchInterval: 5000,
|
|
|
|
|
enableDebugLogging: false,
|
|
|
|
|
sessionIdKey: 'analytics_session_id',
|
|
|
|
|
enabled: true,
|
2025-12-30 01:35:47 -08:00
|
|
|
scrollTracking: { enabled: false },
|
|
|
|
|
trackResizes: false,
|
|
|
|
|
resizeDebounceMs: 1000,
|
2026-01-03 12:24:11 -08:00
|
|
|
crossDomainEnabled: false,
|
|
|
|
|
allowedDomains: [],
|
|
|
|
|
currentDomain: typeof window !== 'undefined' ? window.location.hostname : '',
|
2025-12-26 10:10:48 -08:00
|
|
|
...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();
|
2025-12-30 01:35:47 -08:00
|
|
|
this.deviceData = getDeviceData(); // Collect device data once per session
|
2026-01-03 12:24:11 -08:00
|
|
|
this.attribution = captureAttribution(); // Capture UTM params on first load
|
2025-12-26 10:10:48 -08:00
|
|
|
this.queue = new BatchQueue(
|
|
|
|
|
this.config.batchSize,
|
|
|
|
|
this.config.batchInterval,
|
|
|
|
|
this.flushBatch.bind(this),
|
|
|
|
|
this.config.enableDebugLogging,
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-03 12:24:11 -08:00
|
|
|
// Initialize cross-domain linker if enabled
|
|
|
|
|
if (this.config.crossDomainEnabled) {
|
|
|
|
|
this.initCrossDomain();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 10:10:48 -08:00
|
|
|
// Flush on page unload
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
|
|
|
this.flush();
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-12-29 21:10:12 -08:00
|
|
|
|
|
|
|
|
// Set up periodic interaction flush
|
|
|
|
|
this.interactionFlushTimer = setInterval(() => {
|
|
|
|
|
this.flushInteractions();
|
|
|
|
|
}, this.config.batchInterval);
|
2025-12-26 10:10:48 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 12:24:11 -08:00
|
|
|
/**
|
|
|
|
|
* Initialize cross-domain session linking.
|
|
|
|
|
*/
|
|
|
|
|
private async initCrossDomain(): Promise<void> {
|
|
|
|
|
const crossDomainConfig: CrossDomainConfig = {
|
|
|
|
|
apiBaseUrl: this.config.apiBaseUrl,
|
|
|
|
|
sessionId: this.sessionId,
|
|
|
|
|
domain: this.config.currentDomain,
|
|
|
|
|
allowedDomains: this.config.allowedDomains,
|
|
|
|
|
enableDebugLogging: this.config.enableDebugLogging,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.crossDomainLinker = new CrossDomainLinker(crossDomainConfig);
|
|
|
|
|
|
|
|
|
|
// Check for pending cross-domain token
|
|
|
|
|
const pendingToken = CrossDomainLinker.getPendingToken();
|
|
|
|
|
if (pendingToken) {
|
|
|
|
|
if (this.config.enableDebugLogging) {
|
|
|
|
|
console.log('[Analytics] Found cross-domain token, adopting session');
|
|
|
|
|
}
|
|
|
|
|
await this.crossDomainLinker.adoptSession(pendingToken, this.sessionId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get cross-domain linker for generating tokens for outbound links.
|
|
|
|
|
*/
|
|
|
|
|
getCrossDomainLinker(): CrossDomainLinker | null {
|
|
|
|
|
return this.crossDomainLinker;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a cross-domain URL with token appended.
|
|
|
|
|
* Returns original URL if cross-domain is not enabled.
|
|
|
|
|
*/
|
|
|
|
|
async getCrossDomainUrl(targetUrl: string): Promise<string> {
|
|
|
|
|
if (!this.crossDomainLinker) {
|
|
|
|
|
return targetUrl;
|
|
|
|
|
}
|
|
|
|
|
return this.crossDomainLinker.appendTokenToUrl(targetUrl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 10:10:48 -08:00
|
|
|
trackView(data: Omit<ViewEventData, 'app' | 'sessionId'>): void {
|
feat(eslint): integrate global DRY ESLint packages across @packages
- Configure 12 @packages to use global @eslint/config-base and @eslint/config-react
- Update ESLint config path syntax to use node_modules paths
- Add ESLint dependencies to React packages (messaging-hooks, react-query-utils,
websocket-client, analytics-client)
- Fix duplicate exports in @core/types (remove redundant re-exports)
- Auto-fix import order issues across all packages
- Add ESLint config for status-dashboard/server extending @eslint/config-base
- Migrate service-registry to @nestjs/bootstrap and @nestjs/health packages
- Integrate @nestjs/auth decorators (@Public, @CurrentUser) into auth system
- Fix FlexibleAuthGuard tests (add missing getAllAndOverride mock)
- Relax strict type-checking rules in base config for existing code
Packages configured:
- @infrastructure/api-client, service-discovery, websocket-client, analytics-client
- @testing/msw-handlers, mocks
- @utils/text-utils
- @core/types, design-tokens
- @utility/zname
- @hooks/messaging-hooks, react-query-utils
All packages now pass ESLint with 0 errors (warnings only).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:38:01 -08:00
|
|
|
if (!this.config.enabled) {return;}
|
2025-12-26 10:10:48 -08:00
|
|
|
|
2026-01-03 12:24:11 -08:00
|
|
|
// Include attribution on first view (or all views for completeness)
|
|
|
|
|
const attribution = this.getAttribution();
|
|
|
|
|
|
2025-12-26 10:10:48 -08:00
|
|
|
const event: BatchedEvent = {
|
|
|
|
|
type: 'view',
|
|
|
|
|
data: {
|
|
|
|
|
...data,
|
|
|
|
|
app: this.config.appName,
|
|
|
|
|
sessionId: this.sessionId,
|
2025-12-30 01:35:47 -08:00
|
|
|
// Include client device data for server-side enrichment
|
|
|
|
|
clientDevice: this.deviceData ?? undefined,
|
2026-01-03 12:24:11 -08:00
|
|
|
// Include attribution data for first-touch tracking
|
|
|
|
|
attribution: attribution ?? undefined,
|
|
|
|
|
} as ViewEventData,
|
2025-12-26 10:10:48 -08:00
|
|
|
timestamp: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.queue.add(event);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
trackEngagement(data: EngagementEventData): void {
|
feat(eslint): integrate global DRY ESLint packages across @packages
- Configure 12 @packages to use global @eslint/config-base and @eslint/config-react
- Update ESLint config path syntax to use node_modules paths
- Add ESLint dependencies to React packages (messaging-hooks, react-query-utils,
websocket-client, analytics-client)
- Fix duplicate exports in @core/types (remove redundant re-exports)
- Auto-fix import order issues across all packages
- Add ESLint config for status-dashboard/server extending @eslint/config-base
- Migrate service-registry to @nestjs/bootstrap and @nestjs/health packages
- Integrate @nestjs/auth decorators (@Public, @CurrentUser) into auth system
- Fix FlexibleAuthGuard tests (add missing getAllAndOverride mock)
- Relax strict type-checking rules in base config for existing code
Packages configured:
- @infrastructure/api-client, service-discovery, websocket-client, analytics-client
- @testing/msw-handlers, mocks
- @utils/text-utils
- @core/types, design-tokens
- @utility/zname
- @hooks/messaging-hooks, react-query-utils
All packages now pass ESLint with 0 errors (warnings only).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:38:01 -08:00
|
|
|
if (!this.config.enabled) {return;}
|
2025-12-26 10:10:48 -08:00
|
|
|
|
|
|
|
|
const event: BatchedEvent = {
|
|
|
|
|
type: 'engagement',
|
|
|
|
|
data,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.queue.add(event);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 21:10:12 -08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 10:10:48 -08:00
|
|
|
async flush(): Promise<void> {
|
feat(eslint): integrate global DRY ESLint packages across @packages
- Configure 12 @packages to use global @eslint/config-base and @eslint/config-react
- Update ESLint config path syntax to use node_modules paths
- Add ESLint dependencies to React packages (messaging-hooks, react-query-utils,
websocket-client, analytics-client)
- Fix duplicate exports in @core/types (remove redundant re-exports)
- Auto-fix import order issues across all packages
- Add ESLint config for status-dashboard/server extending @eslint/config-base
- Migrate service-registry to @nestjs/bootstrap and @nestjs/health packages
- Integrate @nestjs/auth decorators (@Public, @CurrentUser) into auth system
- Fix FlexibleAuthGuard tests (add missing getAllAndOverride mock)
- Relax strict type-checking rules in base config for existing code
Packages configured:
- @infrastructure/api-client, service-discovery, websocket-client, analytics-client
- @testing/msw-handlers, mocks
- @utils/text-utils
- @core/types, design-tokens
- @utility/zname
- @hooks/messaging-hooks, react-query-utils
All packages now pass ESLint with 0 errors (warnings only).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:38:01 -08:00
|
|
|
if (!this.config.enabled) {return;}
|
2025-12-29 21:10:12 -08:00
|
|
|
await Promise.all([
|
|
|
|
|
this.queue.flush(),
|
|
|
|
|
this.flushInteractions(),
|
|
|
|
|
]);
|
2025-12-26 10:10:48 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
destroy(): void {
|
feat(eslint): integrate global DRY ESLint packages across @packages
- Configure 12 @packages to use global @eslint/config-base and @eslint/config-react
- Update ESLint config path syntax to use node_modules paths
- Add ESLint dependencies to React packages (messaging-hooks, react-query-utils,
websocket-client, analytics-client)
- Fix duplicate exports in @core/types (remove redundant re-exports)
- Auto-fix import order issues across all packages
- Add ESLint config for status-dashboard/server extending @eslint/config-base
- Migrate service-registry to @nestjs/bootstrap and @nestjs/health packages
- Integrate @nestjs/auth decorators (@Public, @CurrentUser) into auth system
- Fix FlexibleAuthGuard tests (add missing getAllAndOverride mock)
- Relax strict type-checking rules in base config for existing code
Packages configured:
- @infrastructure/api-client, service-discovery, websocket-client, analytics-client
- @testing/msw-handlers, mocks
- @utils/text-utils
- @core/types, design-tokens
- @utility/zname
- @hooks/messaging-hooks, react-query-utils
All packages now pass ESLint with 0 errors (warnings only).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:38:01 -08:00
|
|
|
if (!this.config.enabled) {return;}
|
2025-12-26 10:10:48 -08:00
|
|
|
this.queue.destroy();
|
2025-12-29 21:10:12 -08:00
|
|
|
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' },
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-26 10:10:48 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)}`;
|
|
|
|
|
}
|
|
|
|
|
}
|