@codai/memorai
Version:
Universal Database & Storage Service for CODAI Ecosystem - CBD Backend
418 lines • 15.6 kB
JavaScript
/**
* Analytics Service - Production Implementation
* Handles event tracking, metrics collection, and analytics data processing
*/
import { EventEmitter } from 'events';
import { createHash } from 'crypto';
export class AnalyticsService extends EventEmitter {
constructor() {
super();
this.isInitialized = false;
this.events = new Map();
this.metrics = new Map();
this.batchQueue = [];
this.batchSize = 100;
this.flushInterval = 10000; // 10 seconds
}
async initialize() {
try {
// Start batch processing
this.startBatchProcessing();
// Start metrics aggregation
this.startMetricsAggregation();
this.isInitialized = true;
this.emit('initialized');
console.log('📊 Analytics Service initialized');
}
catch (error) {
console.error('Failed to initialize analytics service:', error);
this.emit('error', error);
throw error;
}
}
async shutdown() {
if (this.isInitialized) {
// Flush remaining events
await this.flushBatch();
// Stop processing
if (this.processInterval) {
clearInterval(this.processInterval);
}
// Clean up data
this.events.clear();
this.metrics.clear();
this.batchQueue = [];
this.isInitialized = false;
this.emit('shutdown');
console.log('📊 Analytics Service shutdown');
}
}
async track(event) {
if (!this.isInitialized) {
throw new Error('Analytics service not initialized');
}
try {
// Generate unique event ID
const eventId = this.generateEventId(event);
// Create processed event
const processedEvent = {
...event,
id: eventId,
processed: false,
timestamp: event.timestamp || new Date()
};
// Validate event
this.validateEvent(processedEvent);
// Add to batch queue
this.batchQueue.push(processedEvent);
// Store in memory for querying
this.events.set(eventId, processedEvent);
// Update metrics
this.updateMetrics(processedEvent);
this.emit('analytics:tracked', { event: processedEvent });
// Flush if batch is full
if (this.batchQueue.length >= this.batchSize) {
await this.flushBatch();
}
}
catch (error) {
console.error('Analytics tracking error:', error);
this.emit('analytics:error', { event, error });
throw error;
}
}
async query(query) {
if (!this.isInitialized) {
throw new Error('Analytics service not initialized');
}
try {
const startTime = Date.now();
// Filter events based on query
const filteredEvents = this.filterEvents(query);
// Apply aggregations
const aggregations = this.applyAggregations(filteredEvents, query.aggregations || []);
// Group by specified fields
const groupedData = this.groupEvents(filteredEvents, query.groupBy || []);
// Calculate summary
const uniqueUsers = new Set(filteredEvents.map(e => e.userId).filter(Boolean)).size;
const result = {
data: groupedData,
summary: {
totalEvents: filteredEvents.length,
uniqueUsers,
dateRange: {
start: query.startDate,
end: query.endDate
},
aggregations
}
};
const queryTime = Date.now() - startTime;
this.emit('analytics:queried', { query, resultCount: filteredEvents.length, queryTime });
return result;
}
catch (error) {
console.error('Analytics query error:', error);
this.emit('analytics:query_error', { query, error });
throw error;
}
}
async getEventCount(eventName, userId, appId) {
let count = 0;
for (const event of this.events.values()) {
if (eventName && event.eventName !== eventName)
continue;
if (userId && event.userId !== userId)
continue;
if (appId && event.appId !== appId)
continue;
count++;
}
return count;
}
async getUniqueUsers(appId, startDate, endDate) {
const users = new Set();
for (const event of this.events.values()) {
if (appId && event.appId !== appId)
continue;
if (startDate && event.timestamp < startDate)
continue;
if (endDate && event.timestamp > endDate)
continue;
if (!event.userId)
continue;
users.add(event.userId);
}
return Array.from(users);
}
async getTopEvents(limit = 10, appId) {
const eventCounts = new Map();
for (const event of this.events.values()) {
if (appId && event.appId !== appId)
continue;
const count = eventCounts.get(event.eventName) || 0;
eventCounts.set(event.eventName, count + 1);
}
return Array.from(eventCounts.entries())
.map(([eventName, count]) => ({ eventName, count }))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
}
async getMetricsSummary() {
const summary = {};
for (const [eventName, metric] of this.metrics.entries()) {
summary[eventName] = {
...metric,
properties: Object.fromEntries(Object.entries(metric.properties).map(([key, valueSet]) => [
key,
Array.from(valueSet)
]))
};
}
return summary;
}
async purgeOldEvents(olderThanDays = 30) {
const cutoffDate = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
let purged = 0;
for (const [eventId, event] of this.events.entries()) {
if (event.timestamp < cutoffDate) {
this.events.delete(eventId);
purged++;
}
}
this.emit('analytics:purged', { count: purged, cutoffDate });
return purged;
}
async getHealth() {
if (!this.isInitialized) {
return { status: 'unhealthy', details: { initialized: false } };
}
try {
const memoryUsage = process.memoryUsage();
return {
status: 'healthy',
details: {
initialized: true,
totalEvents: this.events.size,
queuedEvents: this.batchQueue.length,
uniqueEventTypes: this.metrics.size,
memoryUsage: {
rss: Math.round(memoryUsage.rss / 1024 / 1024),
heapUsed: Math.round(memoryUsage.heapUsed / 1024 / 1024)
}
}
};
}
catch (error) {
return {
status: 'unhealthy',
details: {
error: error instanceof Error ? error.message : 'Unknown error'
}
};
}
}
// ==================== PRIVATE METHODS ====================
generateEventId(event) {
const data = `${event.eventName}_${event.userId}_${event.appId}_${event.timestamp.getTime()}_${JSON.stringify(event.properties)}`;
return createHash('sha256').update(data).digest('hex').substring(0, 16);
}
validateEvent(event) {
if (!event.eventName || event.eventName.trim().length === 0) {
throw new Error('Event name is required');
}
if (!event.appId || event.appId.trim().length === 0) {
throw new Error('App ID is required');
}
if (!event.timestamp || !(event.timestamp instanceof Date)) {
throw new Error('Valid timestamp is required');
}
// Sanitize event name
event.eventName = event.eventName.replace(/[^a-zA-Z0-9._-]/g, '_');
}
updateMetrics(event) {
const metric = this.metrics.get(event.eventName) || {
eventName: event.eventName,
count: 0,
uniqueUsers: 0,
lastSeen: event.timestamp,
properties: {}
};
metric.count++;
metric.lastSeen = event.timestamp;
// Track unique users (approximate)
if (event.userId) {
const userKey = `users_${event.eventName}`;
if (!this.metrics.has(userKey)) {
metric.userSet = new Set();
}
metric.userSet.add(event.userId);
metric.uniqueUsers = metric.userSet.size;
}
// Track property values
for (const [key, value] of Object.entries(event.properties)) {
if (!metric.properties[key]) {
metric.properties[key] = new Set();
}
metric.properties[key].add(value);
}
this.metrics.set(event.eventName, metric);
}
startBatchProcessing() {
this.processInterval = setInterval(async () => {
if (this.batchQueue.length > 0) {
await this.flushBatch();
}
}, this.flushInterval);
}
startMetricsAggregation() {
// Update metrics every minute
setInterval(() => {
this.aggregateMetrics();
}, 60000);
}
async flushBatch() {
if (this.batchQueue.length === 0)
return;
try {
const batch = [...this.batchQueue];
this.batchQueue = [];
// Generate batch ID
const batchId = `batch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Mark events as processed
for (const event of batch) {
event.processed = true;
event.batchId = batchId;
}
// In production, this would send to external analytics service
// (Google Analytics, Mixpanel, custom backend, etc.)
await this.processBatch(batch, batchId);
this.emit('analytics:batch_processed', { batchId, count: batch.length });
}
catch (error) {
console.error('Batch processing error:', error);
this.emit('analytics:batch_error', { error });
// Re-queue failed events
this.batchQueue.unshift(...this.batchQueue);
}
}
async processBatch(batch, batchId) {
// Mock batch processing - in production this would:
// 1. Send to external analytics service
// 2. Store in database
// 3. Update dashboards
console.log(`📊 Processing analytics batch ${batchId} with ${batch.length} events`);
// Simulate processing time
await new Promise(resolve => setTimeout(resolve, 50));
}
aggregateMetrics() {
// This would typically aggregate metrics into time-based buckets
// for efficient querying and visualization
console.log(`📊 Aggregating metrics for ${this.metrics.size} event types`);
}
filterEvents(query) {
const events = [];
for (const event of this.events.values()) {
// Event name filter
if (query.eventName && event.eventName !== query.eventName)
continue;
// User filter
if (query.userId && event.userId !== query.userId)
continue;
// App filter
if (query.appId && event.appId !== query.appId)
continue;
// Date range filter
if (event.timestamp < query.startDate || event.timestamp > query.endDate)
continue;
// Property filters
if (query.filters) {
let matchesFilters = true;
for (const [key, value] of Object.entries(query.filters)) {
if (event.properties[key] !== value) {
matchesFilters = false;
break;
}
}
if (!matchesFilters)
continue;
}
events.push(event);
}
return events;
}
applyAggregations(events, aggregations) {
const results = {};
for (const aggregation of aggregations) {
const key = `${aggregation.field}_${aggregation.operation}`;
const values = [];
for (const event of events) {
const value = event.properties[aggregation.field];
if (typeof value === 'number') {
values.push(value);
}
else if (typeof value === 'string' && !isNaN(Number(value))) {
values.push(Number(value));
}
}
switch (aggregation.operation) {
case 'sum':
results[key] = values.reduce((sum, val) => sum + val, 0);
break;
case 'count':
results[key] = values.length;
break;
case 'avg':
results[key] = values.length > 0 ? values.reduce((sum, val) => sum + val, 0) / values.length : 0;
break;
case 'min':
results[key] = values.length > 0 ? Math.min(...values) : 0;
break;
case 'max':
results[key] = values.length > 0 ? Math.max(...values) : 0;
break;
case 'distinct':
results[key] = new Set(values).size;
break;
}
}
return results;
}
groupEvents(events, groupBy) {
if (groupBy.length === 0) {
// No grouping, return summary
return [{
totalEvents: events.length,
events: events.slice(0, 100) // Limit for performance
}];
}
const groups = new Map();
for (const event of events) {
const groupKey = groupBy.map(field => {
if (field === 'eventName')
return event.eventName;
if (field === 'userId')
return event.userId || 'anonymous';
if (field === 'appId')
return event.appId;
return event.properties[field] || 'unknown';
}).join('|');
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey).push(event);
}
return Array.from(groups.entries()).map(([groupKey, groupEvents]) => {
const groupValues = groupKey.split('|');
const result = {
count: groupEvents.length
};
// Add group fields
groupBy.forEach((field, index) => {
result[field] = groupValues[index];
});
return result;
});
}
}
//# sourceMappingURL=AnalyticsService.js.map