|
@@ -0,0 +1,353 @@
|
|
|
|
|
+import { v4 as uuidv4 } from 'uuid';
|
|
|
|
|
+import { logger } from '../utils/logger';
|
|
|
|
|
+import { ShopDatabase, McpLog } from '../database/Database';
|
|
|
|
|
+
|
|
|
|
|
+export interface McpLogEntry {
|
|
|
|
|
+ shopId: string;
|
|
|
|
|
+ toolName: string;
|
|
|
|
|
+ query: string;
|
|
|
|
|
+ responseSummary: string | null;
|
|
|
|
|
+ executionTimeMs: number;
|
|
|
|
|
+ resultsCount: number;
|
|
|
|
|
+ success: boolean;
|
|
|
|
|
+ ipAddress: string | null;
|
|
|
|
|
+ userAgent: string | null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface McpLogAnalytics {
|
|
|
|
|
+ totalCalls: number;
|
|
|
|
|
+ successfulCalls: number;
|
|
|
|
|
+ failedCalls: number;
|
|
|
|
|
+ averageExecutionTime: number;
|
|
|
|
|
+ averageResultsCount: number;
|
|
|
|
|
+ topTools: { tool: string; count: number }[];
|
|
|
|
|
+ topShops: { shopId: string; count: number }[];
|
|
|
|
|
+ recentErrors: McpLog[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class McpLogger {
|
|
|
|
|
+ constructor(private database: ShopDatabase) {}
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Log a tool call
|
|
|
|
|
+ */
|
|
|
|
|
+ async logToolCall(entry: McpLogEntry): Promise<void> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const id = uuidv4();
|
|
|
|
|
+ const now = new Date().toISOString();
|
|
|
|
|
+
|
|
|
|
|
+ const logEntry: Omit<McpLog, 'id'> & { id: string } = {
|
|
|
|
|
+ id,
|
|
|
|
|
+ shop_id: entry.shopId,
|
|
|
|
|
+ tool_name: entry.toolName,
|
|
|
|
|
+ query: entry.query,
|
|
|
|
|
+ response_summary: entry.responseSummary,
|
|
|
|
|
+ execution_time_ms: entry.executionTimeMs,
|
|
|
|
|
+ results_count: entry.resultsCount,
|
|
|
|
|
+ created_at: now,
|
|
|
|
|
+ ip_address: entry.ipAddress,
|
|
|
|
|
+ user_agent: entry.userAgent,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ this.database.createMcpLog(logEntry);
|
|
|
|
|
+
|
|
|
|
|
+ // Log to application logger as well
|
|
|
|
|
+ if (entry.success) {
|
|
|
|
|
+ logger.debug(`MCP call logged: ${entry.toolName} for shop ${entry.shopId}`, {
|
|
|
|
|
+ executionTime: entry.executionTimeMs,
|
|
|
|
|
+ resultsCount: entry.resultsCount,
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ logger.warn(`MCP call failed and logged: ${entry.toolName} for shop ${entry.shopId}`, {
|
|
|
|
|
+ error: entry.responseSummary,
|
|
|
|
|
+ executionTime: entry.executionTimeMs,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Failed to log MCP tool call:', error);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get MCP logs for a specific shop
|
|
|
|
|
+ */
|
|
|
|
|
+ getShopLogs(shopId: string, limit: number = 100): McpLog[] {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return this.database.getMcpLogs(shopId, limit);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error(`Failed to get MCP logs for shop ${shopId}:`, error);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get all MCP logs
|
|
|
|
|
+ */
|
|
|
|
|
+ getAllLogs(limit: number = 100): McpLog[] {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return this.database.getMcpLogs(undefined, limit);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Failed to get all MCP logs:', error);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get analytics for MCP usage
|
|
|
|
|
+ */
|
|
|
|
|
+ getAnalytics(shopId?: string, daysPast: number = 30): McpLogAnalytics {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const cutoffDate = new Date();
|
|
|
|
|
+ cutoffDate.setDate(cutoffDate.getDate() - daysPast);
|
|
|
|
|
+ const cutoffIso = cutoffDate.toISOString();
|
|
|
|
|
+
|
|
|
|
|
+ // Get recent logs
|
|
|
|
|
+ let logs: McpLog[];
|
|
|
|
|
+ if (shopId) {
|
|
|
|
|
+ logs = this.database.getMcpLogs(shopId, 1000);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ logs = this.database.getMcpLogs(undefined, 1000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Filter by date
|
|
|
|
|
+ const recentLogs = logs.filter(log => log.created_at >= cutoffIso);
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate basic stats
|
|
|
|
|
+ const totalCalls = recentLogs.length;
|
|
|
|
|
+ const successfulCalls = recentLogs.filter(log =>
|
|
|
|
|
+ !log.response_summary?.startsWith('Error:')
|
|
|
|
|
+ ).length;
|
|
|
|
|
+ const failedCalls = totalCalls - successfulCalls;
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate averages
|
|
|
|
|
+ const totalExecutionTime = recentLogs.reduce((sum, log) => sum + log.execution_time_ms, 0);
|
|
|
|
|
+ const totalResults = recentLogs.reduce((sum, log) => sum + log.results_count, 0);
|
|
|
|
|
+ const averageExecutionTime = totalCalls > 0 ? totalExecutionTime / totalCalls : 0;
|
|
|
|
|
+ const averageResultsCount = totalCalls > 0 ? totalResults / totalCalls : 0;
|
|
|
|
|
+
|
|
|
|
|
+ // Top tools
|
|
|
|
|
+ const toolCounts = new Map<string, number>();
|
|
|
|
|
+ recentLogs.forEach(log => {
|
|
|
|
|
+ toolCounts.set(log.tool_name, (toolCounts.get(log.tool_name) || 0) + 1);
|
|
|
|
|
+ });
|
|
|
|
|
+ const topTools = Array.from(toolCounts.entries())
|
|
|
|
|
+ .map(([tool, count]) => ({ tool, count }))
|
|
|
|
|
+ .sort((a, b) => b.count - a.count)
|
|
|
|
|
+ .slice(0, 10);
|
|
|
|
|
+
|
|
|
|
|
+ // Top shops (if not filtering by specific shop)
|
|
|
|
|
+ const topShops: { shopId: string; count: number }[] = [];
|
|
|
|
|
+ if (!shopId) {
|
|
|
|
|
+ const shopCounts = new Map<string, number>();
|
|
|
|
|
+ recentLogs.forEach(log => {
|
|
|
|
|
+ shopCounts.set(log.shop_id, (shopCounts.get(log.shop_id) || 0) + 1);
|
|
|
|
|
+ });
|
|
|
|
|
+ topShops.push(...Array.from(shopCounts.entries())
|
|
|
|
|
+ .map(([shopId, count]) => ({ shopId, count }))
|
|
|
|
|
+ .sort((a, b) => b.count - a.count)
|
|
|
|
|
+ .slice(0, 10));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Recent errors
|
|
|
|
|
+ const recentErrors = recentLogs
|
|
|
|
|
+ .filter(log => log.response_summary?.startsWith('Error:'))
|
|
|
|
|
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
|
|
|
|
+ .slice(0, 10);
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ totalCalls,
|
|
|
|
|
+ successfulCalls,
|
|
|
|
|
+ failedCalls,
|
|
|
|
|
+ averageExecutionTime,
|
|
|
|
|
+ averageResultsCount,
|
|
|
|
|
+ topTools,
|
|
|
|
|
+ topShops,
|
|
|
|
|
+ recentErrors,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Failed to get MCP analytics:', error);
|
|
|
|
|
+ return {
|
|
|
|
|
+ totalCalls: 0,
|
|
|
|
|
+ successfulCalls: 0,
|
|
|
|
|
+ failedCalls: 0,
|
|
|
|
|
+ averageExecutionTime: 0,
|
|
|
|
|
+ averageResultsCount: 0,
|
|
|
|
|
+ topTools: [],
|
|
|
|
|
+ topShops: [],
|
|
|
|
|
+ recentErrors: [],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get performance metrics for a specific tool
|
|
|
|
|
+ */
|
|
|
|
|
+ getToolPerformance(toolName: string, shopId?: string): {
|
|
|
|
|
+ totalCalls: number;
|
|
|
|
|
+ averageExecutionTime: number;
|
|
|
|
|
+ averageResultsCount: number;
|
|
|
|
|
+ successRate: number;
|
|
|
|
|
+ recentCalls: McpLog[];
|
|
|
|
|
+ } {
|
|
|
|
|
+ try {
|
|
|
|
|
+ let logs: McpLog[];
|
|
|
|
|
+ if (shopId) {
|
|
|
|
|
+ logs = this.database.getMcpLogs(shopId, 1000);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ logs = this.database.getMcpLogs(undefined, 1000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Filter by tool name
|
|
|
|
|
+ const toolLogs = logs.filter(log => log.tool_name === toolName);
|
|
|
|
|
+
|
|
|
|
|
+ const totalCalls = toolLogs.length;
|
|
|
|
|
+ const successfulCalls = toolLogs.filter(log =>
|
|
|
|
|
+ !log.response_summary?.startsWith('Error:')
|
|
|
|
|
+ ).length;
|
|
|
|
|
+ const successRate = totalCalls > 0 ? successfulCalls / totalCalls : 0;
|
|
|
|
|
+
|
|
|
|
|
+ const totalExecutionTime = toolLogs.reduce((sum, log) => sum + log.execution_time_ms, 0);
|
|
|
|
|
+ const totalResults = toolLogs.reduce((sum, log) => sum + log.results_count, 0);
|
|
|
|
|
+ const averageExecutionTime = totalCalls > 0 ? totalExecutionTime / totalCalls : 0;
|
|
|
|
|
+ const averageResultsCount = totalCalls > 0 ? totalResults / totalCalls : 0;
|
|
|
|
|
+
|
|
|
|
|
+ const recentCalls = toolLogs
|
|
|
|
|
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
|
|
|
|
+ .slice(0, 20);
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ totalCalls,
|
|
|
|
|
+ averageExecutionTime,
|
|
|
|
|
+ averageResultsCount,
|
|
|
|
|
+ successRate,
|
|
|
|
|
+ recentCalls,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error(`Failed to get performance metrics for tool ${toolName}:`, error);
|
|
|
|
|
+ return {
|
|
|
|
|
+ totalCalls: 0,
|
|
|
|
|
+ averageExecutionTime: 0,
|
|
|
|
|
+ averageResultsCount: 0,
|
|
|
|
|
+ successRate: 0,
|
|
|
|
|
+ recentCalls: [],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Get usage trends over time
|
|
|
|
|
+ */
|
|
|
|
|
+ getUsageTrends(shopId?: string, days: number = 30): {
|
|
|
|
|
+ daily: { date: string; calls: number; averageTime: number }[];
|
|
|
|
|
+ hourly: { hour: number; calls: number; averageTime: number }[];
|
|
|
|
|
+ } {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const cutoffDate = new Date();
|
|
|
|
|
+ cutoffDate.setDate(cutoffDate.getDate() - days);
|
|
|
|
|
+ const cutoffIso = cutoffDate.toISOString();
|
|
|
|
|
+
|
|
|
|
|
+ let logs: McpLog[];
|
|
|
|
|
+ if (shopId) {
|
|
|
|
|
+ logs = this.database.getMcpLogs(shopId, 10000);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ logs = this.database.getMcpLogs(undefined, 10000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Filter by date
|
|
|
|
|
+ const recentLogs = logs.filter(log => log.created_at >= cutoffIso);
|
|
|
|
|
+
|
|
|
|
|
+ // Daily trends
|
|
|
|
|
+ const dailyData = new Map<string, { calls: number; totalTime: number }>();
|
|
|
|
|
+
|
|
|
|
|
+ recentLogs.forEach(log => {
|
|
|
|
|
+ const date = log.created_at.split('T')[0]; // Get YYYY-MM-DD
|
|
|
|
|
+ const existing = dailyData.get(date) || { calls: 0, totalTime: 0 };
|
|
|
|
|
+ dailyData.set(date, {
|
|
|
|
|
+ calls: existing.calls + 1,
|
|
|
|
|
+ totalTime: existing.totalTime + log.execution_time_ms,
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const daily = Array.from(dailyData.entries())
|
|
|
|
|
+ .map(([date, data]) => ({
|
|
|
|
|
+ date,
|
|
|
|
|
+ calls: data.calls,
|
|
|
|
|
+ averageTime: data.calls > 0 ? data.totalTime / data.calls : 0,
|
|
|
|
|
+ }))
|
|
|
|
|
+ .sort((a, b) => a.date.localeCompare(b.date));
|
|
|
|
|
+
|
|
|
|
|
+ // Hourly trends (aggregated across all days)
|
|
|
|
|
+ const hourlyData = new Map<number, { calls: number; totalTime: number }>();
|
|
|
|
|
+
|
|
|
|
|
+ recentLogs.forEach(log => {
|
|
|
|
|
+ const hour = new Date(log.created_at).getHours();
|
|
|
|
|
+ const existing = hourlyData.get(hour) || { calls: 0, totalTime: 0 };
|
|
|
|
|
+ hourlyData.set(hour, {
|
|
|
|
|
+ calls: existing.calls + 1,
|
|
|
|
|
+ totalTime: existing.totalTime + log.execution_time_ms,
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const hourly = Array.from({ length: 24 }, (_, hour) => {
|
|
|
|
|
+ const data = hourlyData.get(hour) || { calls: 0, totalTime: 0 };
|
|
|
|
|
+ return {
|
|
|
|
|
+ hour,
|
|
|
|
|
+ calls: data.calls,
|
|
|
|
|
+ averageTime: data.calls > 0 ? data.totalTime / data.calls : 0,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return { daily, hourly };
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Failed to get usage trends:', error);
|
|
|
|
|
+ return { daily: [], hourly: [] };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Search logs by query content
|
|
|
|
|
+ */
|
|
|
|
|
+ searchLogs(
|
|
|
|
|
+ searchTerm: string,
|
|
|
|
|
+ shopId?: string,
|
|
|
|
|
+ toolName?: string,
|
|
|
|
|
+ limit: number = 50
|
|
|
|
|
+ ): McpLog[] {
|
|
|
|
|
+ try {
|
|
|
|
|
+ let logs: McpLog[];
|
|
|
|
|
+ if (shopId) {
|
|
|
|
|
+ logs = this.database.getMcpLogs(shopId, 1000);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ logs = this.database.getMcpLogs(undefined, 1000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Apply filters
|
|
|
|
|
+ let filteredLogs = logs;
|
|
|
|
|
+
|
|
|
|
|
+ if (toolName) {
|
|
|
|
|
+ filteredLogs = filteredLogs.filter(log => log.tool_name === toolName);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (searchTerm) {
|
|
|
|
|
+ const lowerSearchTerm = searchTerm.toLowerCase();
|
|
|
|
|
+ filteredLogs = filteredLogs.filter(log =>
|
|
|
|
|
+ log.query.toLowerCase().includes(lowerSearchTerm) ||
|
|
|
|
|
+ log.response_summary?.toLowerCase().includes(lowerSearchTerm)
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Sort by recency and limit
|
|
|
|
|
+ return filteredLogs
|
|
|
|
|
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
|
|
|
|
+ .slice(0, limit);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ logger.error('Failed to search MCP logs:', error);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|