AnalyticsManager.js•12.1 kB
import { promises as fs } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
export class AnalyticsManager {
constructor(factStore, configManager) {
this.factStore = factStore;
this.configManager = configManager;
this.analyticsDir = join(homedir(), '.mcp_sequential_thinking', 'analytics');
this.analyticsFile = join(this.analyticsDir, 'analytics.json');
this.metrics = {
factsStored: 0,
factsRetrieved: 0,
queriesExecuted: 0,
sessionCount: 0,
averageQualityScore: 0,
toolUsage: {},
domainActivity: {},
factTypeDistribution: {},
qualityTrends: [],
performanceMetrics: {},
retentionMetrics: {},
};
this.sessionMetrics = {
startTime: null,
factsProcessed: 0,
queriesExecuted: 0,
toolsUsed: new Set(),
};
this.initialized = false;
}
async initialize() {
try {
await this.ensureAnalyticsDirectory();
await this.loadExistingAnalytics();
this.startSession();
this.initialized = true;
console.log('AnalyticsManager initialized');
} catch (error) {
console.error('Failed to initialize AnalyticsManager:', error);
throw error;
}
}
async ensureAnalyticsDirectory() {
try {
await fs.mkdir(this.analyticsDir, { recursive: true });
} catch (error) {
console.error('Failed to create analytics directory:', error);
throw error;
}
}
async loadExistingAnalytics() {
try {
const data = await fs.readFile(this.analyticsFile, 'utf-8');
const loadedMetrics = JSON.parse(data);
this.metrics = { ...this.metrics, ...loadedMetrics };
} catch (error) {
console.log('No existing analytics found, starting fresh');
}
}
async saveAnalytics() {
try {
const analyticsData = {
...this.metrics,
lastUpdated: new Date().toISOString(),
version: '1.0.0',
};
await fs.writeFile(this.analyticsFile, JSON.stringify(analyticsData, null, 2));
} catch (error) {
console.error('Failed to save analytics:', error);
}
}
startSession() {
this.sessionMetrics = {
startTime: new Date(),
factsProcessed: 0,
queriesExecuted: 0,
toolsUsed: new Set(),
};
this.metrics.sessionCount += 1;
}
async endSession() {
if (!this.sessionMetrics.startTime) return;
const sessionDuration = Date.now() - this.sessionMetrics.startTime.getTime();
this.recordPerformanceMetric('sessionDuration', sessionDuration);
this.recordPerformanceMetric('factsPerSession', this.sessionMetrics.factsProcessed);
this.recordPerformanceMetric('queriesPerSession', this.sessionMetrics.queriesExecuted);
await this.saveAnalytics();
console.log(`Session ended: ${this.sessionMetrics.factsProcessed} facts processed, ${this.sessionMetrics.queriesExecuted} queries executed`);
}
recordFactStored(fact) {
this.metrics.factsStored += 1;
this.sessionMetrics.factsProcessed += 1;
this.updateFactTypeDistribution(fact.type);
this.updateDomainActivity(fact.domain);
this.updateQualityTrends(fact.qualityScore);
this.scheduleAnalyticsSave();
}
recordFactRetrieved(facts) {
this.metrics.factsRetrieved += facts.length;
for (const fact of facts) {
this.updateDomainActivity(fact.domain);
this.updateFactTypeDistribution(fact.type);
}
}
recordQueryExecuted(query, results) {
this.metrics.queriesExecuted += 1;
this.sessionMetrics.queriesExecuted += 1;
this.recordPerformanceMetric('queryResultCount', results.facts.length);
this.recordPerformanceMetric('queryExecutionTime', results.executionTime || 0);
if (query.type) {
this.updateFactTypeDistribution(query.type);
}
if (query.domain) {
this.updateDomainActivity(query.domain);
}
}
recordToolUsage(toolName, duration = 0) {
if (!this.metrics.toolUsage[toolName]) {
this.metrics.toolUsage[toolName] = {
count: 0,
totalDuration: 0,
averageDuration: 0,
};
}
this.metrics.toolUsage[toolName].count += 1;
this.metrics.toolUsage[toolName].totalDuration += duration;
this.metrics.toolUsage[toolName].averageDuration =
this.metrics.toolUsage[toolName].totalDuration / this.metrics.toolUsage[toolName].count;
this.sessionMetrics.toolsUsed.add(toolName);
}
updateFactTypeDistribution(factType) {
if (!this.metrics.factTypeDistribution[factType]) {
this.metrics.factTypeDistribution[factType] = 0;
}
this.metrics.factTypeDistribution[factType] += 1;
}
updateDomainActivity(domain) {
if (!domain) return;
if (!this.metrics.domainActivity[domain]) {
this.metrics.domainActivity[domain] = {
factsStored: 0,
factsRetrieved: 0,
lastActivity: null,
};
}
this.metrics.domainActivity[domain].factsStored += 1;
this.metrics.domainActivity[domain].lastActivity = new Date().toISOString();
}
updateQualityTrends(qualityScore) {
const now = new Date();
const dateKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const existingEntry = this.metrics.qualityTrends.find(entry => entry.date === dateKey);
if (existingEntry) {
existingEntry.scores.push(qualityScore);
existingEntry.average = existingEntry.scores.reduce((a, b) => a + b, 0) / existingEntry.scores.length;
} else {
this.metrics.qualityTrends.push({
date: dateKey,
scores: [qualityScore],
average: qualityScore,
count: 1,
});
}
if (this.metrics.qualityTrends.length > 30) {
this.metrics.qualityTrends = this.metrics.qualityTrends.slice(-30);
}
this.updateAverageQualityScore();
}
updateAverageQualityScore() {
if (this.factStore.factsIndex.size === 0) {
this.metrics.averageQualityScore = 0;
return;
}
const totalScore = Array.from(this.factStore.factsIndex.values())
.reduce((sum, fact) => sum + (fact.qualityScore || 0), 0);
this.metrics.averageQualityScore = Math.round(totalScore / this.factStore.factsIndex.size);
}
recordPerformanceMetric(metricName, value) {
if (!this.metrics.performanceMetrics[metricName]) {
this.metrics.performanceMetrics[metricName] = {
count: 0,
total: 0,
average: 0,
min: Number.MAX_SAFE_INTEGER,
max: 0,
};
}
const metric = this.metrics.performanceMetrics[metricName];
metric.count += 1;
metric.total += value;
metric.average = metric.total / metric.count;
metric.min = Math.min(metric.min, value);
metric.max = Math.max(metric.max, value);
}
async calculateRetentionMetrics() {
const allFacts = Array.from(this.factStore.factsIndex.values());
const now = new Date();
const retentionPeriods = [7, 30, 90, 365];
const retentionData = {};
for (const days of retentionPeriods) {
const cutoffDate = new Date(now.getTime() - (days * 24 * 60 * 60 * 1000));
const factsInPeriod = allFacts.filter(fact => {
const factDate = new Date(fact.createdAt || fact.metadata?.processedAt);
return factDate >= cutoffDate;
});
const accessedFacts = factsInPeriod.filter(fact =>
fact.lastAccessed && new Date(fact.lastAccessed) >= cutoffDate
);
retentionData[`${days}days`] = {
totalFacts: factsInPeriod.length,
accessedFacts: accessedFacts.length,
retentionRate: factsInPeriod.length > 0 ? accessedFacts.length / factsInPeriod.length : 0,
};
}
this.metrics.retentionMetrics = retentionData;
return retentionData;
}
async generateInsights() {
await this.calculateRetentionMetrics();
const insights = [];
if (this.metrics.averageQualityScore < 60) {
insights.push({
type: 'quality_concern',
message: `Average quality score is ${this.metrics.averageQualityScore}/100. Consider reviewing fact processing criteria.`,
severity: 'medium',
});
}
const recentTrend = this.metrics.qualityTrends.slice(-7);
if (recentTrend.length >= 3) {
const isDecreasing = recentTrend.every((entry, index) =>
index === 0 || entry.average < recentTrend[index - 1].average
);
if (isDecreasing) {
insights.push({
type: 'quality_decline',
message: 'Quality scores have been declining over the past week.',
severity: 'high',
});
}
}
const mostUsedDomain = Object.entries(this.metrics.domainActivity)
.sort(([, a], [, b]) => b.factsStored - a.factsStored)[0];
if (mostUsedDomain && mostUsedDomain[1].factsStored > this.metrics.factsStored * 0.5) {
insights.push({
type: 'domain_concentration',
message: `${mostUsedDomain[1].factsStored}% of facts are in ${mostUsedDomain[0]} domain. Consider diversifying knowledge capture.`,
severity: 'low',
});
}
const lowRetentionPeriods = Object.entries(this.metrics.retentionMetrics)
.filter(([, data]) => data.retentionRate < 0.3);
if (lowRetentionPeriods.length > 0) {
insights.push({
type: 'low_retention',
message: `Low fact access rates detected in: ${lowRetentionPeriods.map(([period]) => period).join(', ')}`,
severity: 'medium',
});
}
return insights;
}
async getAnalyticsReport() {
const insights = await this.generateInsights();
return {
summary: {
totalFacts: this.factStore.factsIndex.size,
factsStored: this.metrics.factsStored,
factsRetrieved: this.metrics.factsRetrieved,
queriesExecuted: this.metrics.queriesExecuted,
sessionCount: this.metrics.sessionCount,
averageQualityScore: this.metrics.averageQualityScore,
},
usage: {
toolUsage: this.metrics.toolUsage,
domainActivity: this.metrics.domainActivity,
factTypeDistribution: this.metrics.factTypeDistribution,
},
trends: {
qualityTrends: this.metrics.qualityTrends.slice(-14),
},
performance: this.metrics.performanceMetrics,
retention: this.metrics.retentionMetrics,
insights,
generatedAt: new Date().toISOString(),
};
}
scheduleAnalyticsSave() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
this.saveAnalytics();
}, 30000);
}
async exportAnalytics(format = 'json') {
const report = await this.getAnalyticsReport();
if (format === 'json') {
return JSON.stringify(report, null, 2);
}
if (format === 'csv') {
return this.convertToCSV(report);
}
throw new Error(`Unsupported export format: ${format}`);
}
convertToCSV(report) {
const lines = [];
lines.push('Metric,Value');
lines.push(`Total Facts,${report.summary.totalFacts}`);
lines.push(`Facts Stored,${report.summary.factsStored}`);
lines.push(`Facts Retrieved,${report.summary.factsRetrieved}`);
lines.push(`Queries Executed,${report.summary.queriesExecuted}`);
lines.push(`Average Quality Score,${report.summary.averageQualityScore}`);
lines.push('');
lines.push('Domain,Facts Stored');
for (const [domain, data] of Object.entries(report.usage.domainActivity)) {
lines.push(`${domain},${data.factsStored}`);
}
lines.push('');
lines.push('Fact Type,Count');
for (const [type, count] of Object.entries(report.usage.factTypeDistribution)) {
lines.push(`${type},${count}`);
}
return lines.join('\n');
}
async shutdown() {
if (this.initialized) {
await this.endSession();
await this.saveAnalytics();
console.log('AnalyticsManager shut down successfully');
}
}
}