From 62918c9cbfbf2eb8017302cb3f195a56db20d84e Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 13 Apr 2026 14:12:19 +0200 Subject: [PATCH] added rule stats --- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../TableStorage/TableMetricsRepository.cs | 14 ++ .../Ingest/IngestEventsFunction.Processing.cs | 107 ++++++++- .../Functions/Metrics/RuleStatsFunction.cs | 189 +++++++++++++++ .../Functions/Rules/GetRuleResultsFunction.cs | 6 +- .../Security/EndpointAccessPolicyCatalog.cs | 2 + .../MaintenanceService.Aggregation.cs | 79 +++++++ .../Services/RuleEngine.cs | 26 ++- .../Services/TableStorageService.Metrics.cs | 215 ++++++++++++++++++ .../autopilot-monitor-mcp/src/tools/admin.ts | 32 +++ .../AutopilotMonitor.Shared/Constants.cs | 6 +- .../DataAccess/IMetricsRepository.cs | 8 + .../Models/Metrics/RuleStatsEntry.cs | 79 +++++++ .../components/AnalyzeRuleCard.tsx | 15 ++ .../app/analyze-rules/page.tsx | 50 ++++ .../components/AnalysisResultsSection.tsx | 44 +++- src/Web/autopilot-monitor-web/lib/api.ts | 4 + 17 files changed, 858 insertions(+), 20 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Metrics/RuleStatsEntry.cs diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index ad2b6c57..6a21a128 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -915 +918 diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs index 63d2943f..faa0b362 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs @@ -61,5 +61,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetMetricsSummaryAsync(string? tenantId) => _storage.GetMetricsSummaryAsync(tenantId); + + public Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType, + string ruleTitle, string category, string severity, bool fired, int? confidenceScore) + => _storage.IncrementRuleStatAsync(date, tenantId, ruleId, ruleType, ruleTitle, category, severity, fired, confidenceScore); + + public Task SaveRuleStatsEntryAsync(RuleStatsEntry entry) + => _storage.SaveRuleStatsEntryAsync(entry); + + public Task> GetRuleStatsAsync(string? tenantId = null, string? startDate = null, + string? endDate = null, string? ruleType = null, int maxResults = 500) + => _storage.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType, maxResults); + + public Task DeleteRuleStatsOlderThanAsync(DateTime cutoffDate) + => _storage.DeleteRuleStatsOlderThanAsync(cutoffDate); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs index 5a6f5049..a829f87c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs @@ -266,30 +266,33 @@ namespace AutopilotMonitor.Functions.Functions.Ingest try { var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger); - var results = await ruleEngine.AnalyzeSessionAsync(ruleTenantId, ruleSessionId); + var outcome = await ruleEngine.AnalyzeSessionAsync(ruleTenantId, ruleSessionId); - foreach (var result in results) + foreach (var result in outcome.Results) { await _ruleRepo.StoreRuleResultAsync(result); } - if (results.Count > 0) + if (outcome.Results.Count > 0) { _logger.LogInformation("{Prefix} Enrollment-end analysis (async): {Count} issue(s) detected", - rulePrefix, results.Count); + rulePrefix, outcome.Results.Count); // Push SignalR notification so the UI fetches results immediately await _signalRNotification.NotifyRuleResultsAvailableAsync( - ruleTenantId, ruleSessionId, results.Count); + ruleTenantId, ruleSessionId, outcome.Results.Count); } // Increment platform stats for detected issues - if (results.Count > 0) + if (outcome.Results.Count > 0) { - _ = _metricsRepo.IncrementPlatformStatAsync("IssuesDetected", results.Count) + _ = _metricsRepo.IncrementPlatformStatAsync("IssuesDetected", outcome.Results.Count) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); } + + // Record rule telemetry: increment stats for every evaluated rule + _ = RecordAnalyzeRuleStatsAsync(ruleTenantId, outcome); } catch (Exception ruleEx) { @@ -447,6 +450,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _ = _metricsRepo.IncrementPlatformStatAsync("SuccessfulEnrollments") .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + // Record gather rule telemetry for events that carry ruleId in their data + _ = RecordGatherRuleStatsAsync(request.TenantId, storedEvents); + // Store diagnostics blob name on session (if agent uploaded a diagnostics package) if (classification.DiagnosticsUploadedEvent != null) { @@ -973,6 +979,93 @@ namespace AutopilotMonitor.Functions.Functions.Ingest maxFutureDriftHours); } } + + /// + /// Records gather rule telemetry for events that carry a ruleId in their data. + /// Fire-and-forget — failures are logged but never propagated. + /// Gather rules have no evaluation count (they run on the agent); we only track fires. + /// + private async Task RecordGatherRuleStatsAsync(string tenantId, List events) + { + try + { + var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); + + // Gather rule results carry ruleId in their event data + var gatherEvents = events.Where(e => + e.Data != null && + e.Data.ContainsKey("ruleId") && + e.Source == "GatherRuleExecutor").ToList(); + + if (gatherEvents.Count == 0) return; + + // Deduplicate: count each unique ruleId once per ingest batch + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var evt in gatherEvents) + { + var ruleId = evt.Data!["ruleId"]?.ToString(); + if (string.IsNullOrEmpty(ruleId) || !seen.Add(ruleId)) continue; + + var ruleTitle = evt.Data.ContainsKey("ruleTitle") ? evt.Data["ruleTitle"]?.ToString() ?? "" : ""; + + // Tenant-specific row + await _metricsRepo.IncrementRuleStatAsync( + today, tenantId, ruleId, "gather", + ruleTitle, "", "", + fired: true, confidenceScore: null); + + // Global aggregate row + await _metricsRepo.IncrementRuleStatAsync( + today, "global", ruleId, "gather", + ruleTitle, "", "", + fired: true, confidenceScore: null); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to record gather rule stats (non-fatal)"); + } + } + + /// + /// Records rule telemetry for all evaluated analyze rules. + /// Fire-and-forget — failures are logged but never propagated. + /// + private async Task RecordAnalyzeRuleStatsAsync(string tenantId, AnalysisOutcome outcome) + { + try + { + var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); + var firedRuleIds = new HashSet(outcome.Results.Select(r => r.RuleId)); + + foreach (var rule in outcome.EvaluatedRules) + { + var fired = firedRuleIds.Contains(rule.RuleId); + int? confidence = null; + if (fired) + { + var result = outcome.Results.FirstOrDefault(r => r.RuleId == rule.RuleId); + confidence = result?.ConfidenceScore; + } + + // Tenant-specific row + await _metricsRepo.IncrementRuleStatAsync( + today, tenantId, rule.RuleId, "analyze", + rule.Title, rule.Category, rule.Severity, + fired, confidence); + + // Global aggregate row + await _metricsRepo.IncrementRuleStatAsync( + today, "global", rule.RuleId, "analyze", + rule.Title, rule.Category, rule.Severity, + fired, confidence); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to record analyze rule stats (non-fatal)"); + } + } } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs new file mode 100644 index 00000000..91801477 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs @@ -0,0 +1,189 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Metrics +{ + /// + /// Functions for retrieving rule telemetry stats. + /// Tenant-facing: per-tenant rule effectiveness metrics. + /// Global admin: cross-tenant rule stats and adoption summaries. + /// + public class RuleStatsFunction + { + private readonly ILogger _logger; + private readonly IMetricsRepository _metricsRepo; + + public RuleStatsFunction( + ILogger logger, + IMetricsRepository metricsRepo) + { + _logger = logger; + _metricsRepo = metricsRepo; + } + + /// + /// GET /api/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze + /// Returns rule stats for the caller's tenant (MemberRead). + /// + [Function("GetRuleStats")] + public async Task GetTenantRuleStats( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "metrics/rule-stats")] + HttpRequestData req) + { + try + { + string tenantId = TenantHelper.GetTenantId(req); + var startDate = req.Query["startDate"] ?? DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd"); + var endDate = req.Query["endDate"] ?? DateTime.UtcNow.ToString("yyyy-MM-dd"); + var ruleType = req.Query["ruleType"]; + + _logger.LogInformation("Rule stats requested for tenant {TenantId} ({StartDate} to {EndDate})", + tenantId, startDate, endDate); + + var entries = await _metricsRepo.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType); + + // Aggregate across dates per ruleId + var aggregated = entries + .GroupBy(e => e.RuleId) + .Select(g => new + { + ruleId = g.Key, + ruleType = g.First().RuleType, + ruleTitle = g.First().RuleTitle, + category = g.First().Category, + severity = g.First().Severity, + fireCount = g.Sum(e => e.FireCount), + evaluationCount = g.Sum(e => e.EvaluationCount), + sessionsEvaluated = g.Sum(e => e.SessionsEvaluated), + hitRate = g.Sum(e => e.EvaluationCount) > 0 + ? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1) + : 0.0, + avgConfidenceScore = g.Sum(e => e.FireCount) > 0 + ? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1) + : 0.0, + trend = g.OrderBy(e => e.Date).Select(e => new + { + date = e.Date, + fireCount = e.FireCount, + evaluationCount = e.EvaluationCount + }) + }) + .OrderByDescending(r => r.fireCount) + .ToList(); + + var totalEvaluations = aggregated.Sum(r => r.evaluationCount); + var totalFires = aggregated.Sum(r => r.fireCount); + + var result = new + { + rules = aggregated, + summary = new + { + totalEvaluations, + totalFires, + overallHitRate = totalEvaluations > 0 + ? Math.Round(100.0 * totalFires / totalEvaluations, 1) + : 0.0, + topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId, + period = new { start = startDate, end = endDate } + } + }; + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(result); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching tenant rule stats"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Failed to fetch rule stats" }); + return errorResponse; + } + } + + /// + /// GET /api/global/metrics/rule-stats?startDate=...&endDate=...&ruleType=analyze + /// Returns cross-tenant global rule stats (Global Admin only). + /// + [Function("GetGlobalRuleStats")] + public async Task GetGlobalRuleStats( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/rule-stats")] + HttpRequestData req) + { + try + { + var startDate = req.Query["startDate"] ?? DateTime.UtcNow.AddDays(-30).ToString("yyyy-MM-dd"); + var endDate = req.Query["endDate"] ?? DateTime.UtcNow.ToString("yyyy-MM-dd"); + var ruleType = req.Query["ruleType"]; + + _logger.LogInformation("Global rule stats requested ({StartDate} to {EndDate})", startDate, endDate); + + // Fetch global aggregate rows + var entries = await _metricsRepo.GetRuleStatsAsync("global", startDate, endDate, ruleType); + + var aggregated = entries + .GroupBy(e => e.RuleId) + .Select(g => new + { + ruleId = g.Key, + ruleType = g.First().RuleType, + ruleTitle = g.First().RuleTitle, + category = g.First().Category, + severity = g.First().Severity, + fireCount = g.Sum(e => e.FireCount), + evaluationCount = g.Sum(e => e.EvaluationCount), + sessionsEvaluated = g.Sum(e => e.SessionsEvaluated), + hitRate = g.Sum(e => e.EvaluationCount) > 0 + ? Math.Round(100.0 * g.Sum(e => e.FireCount) / g.Sum(e => e.EvaluationCount), 1) + : 0.0, + avgConfidenceScore = g.Sum(e => e.FireCount) > 0 + ? Math.Round((double)g.Sum(e => e.ConfidenceScoreSum) / g.Sum(e => e.FireCount), 1) + : 0.0, + trend = g.OrderBy(e => e.Date).Select(e => new + { + date = e.Date, + fireCount = e.FireCount, + evaluationCount = e.EvaluationCount + }) + }) + .OrderByDescending(r => r.fireCount) + .ToList(); + + var totalEvaluations = aggregated.Sum(r => r.evaluationCount); + var totalFires = aggregated.Sum(r => r.fireCount); + + var result = new + { + rules = aggregated, + summary = new + { + totalEvaluations, + totalFires, + overallHitRate = totalEvaluations > 0 + ? Math.Round(100.0 * totalFires / totalEvaluations, 1) + : 0.0, + topRuleByFireCount = aggregated.FirstOrDefault()?.ruleId, + uniqueRules = aggregated.Count, + period = new { start = startDate, end = endDate } + } + }; + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(result); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching global rule stats"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { success = false, message = "Failed to fetch global rule stats" }); + return errorResponse; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs index 6a3cc238..08126f07 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs @@ -62,17 +62,17 @@ namespace AutopilotMonitor.Functions.Functions.Rules try { var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger); - var freshResults = await ruleEngine.AnalyzeSessionAsync(effectiveTenantId, sessionId, reanalyze: true); + var outcome = await ruleEngine.AnalyzeSessionAsync(effectiveTenantId, sessionId, reanalyze: true); // Delete existing results so stale entries don't persist after re-analysis await _maintenanceRepo.DeleteSessionRuleResultsAsync(effectiveTenantId, sessionId); - foreach (var result in freshResults) + foreach (var result in outcome.Results) { await _ruleRepo.StoreRuleResultAsync(result); } - _logger.LogInformation($"On-demand re-analysis for session {sessionId}: {freshResults.Count} issue(s) detected"); + _logger.LogInformation($"On-demand re-analysis for session {sessionId}: {outcome.Results.Count} issue(s) detected"); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 138c89b7..7cfc0861 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -144,6 +144,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "apps/{appName}/analytics", EndpointPolicy.MemberRead), new("GET", "apps/{appName}/sessions", EndpointPolicy.MemberRead), new("GET", "metrics/usage", EndpointPolicy.MemberRead), + new("GET", "metrics/rule-stats", EndpointPolicy.MemberRead), new("GET", "metrics/geographic", EndpointPolicy.MemberRead), new("GET", "metrics/mcp-usage/user/{userId}", EndpointPolicy.TenantAdminOrGA), new("GET", "metrics/geographic/sessions", EndpointPolicy.MemberRead), @@ -227,6 +228,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "global/metrics/geographic", EndpointPolicy.GlobalAdminOnly), new("GET", "global/metrics/geographic/sessions", EndpointPolicy.GlobalAdminOnly), new("GET", "global/metrics/usage", EndpointPolicy.GlobalAdminOnly), + new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalAdminOnly), new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalAdminOnly), new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalAdminOnly), new("GET", "global/distress-reports", EndpointPolicy.GlobalAdminOnly), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index 6df4985a..5fba3071 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -81,6 +81,9 @@ namespace AutopilotMonitor.Functions.Services await _metricsRepo.SaveUsageMetricsSnapshotAsync(tenantMetrics); } + // Aggregate rule stats from RuleResults table for this date + await AggregateRuleStatsForDateAsync(targetDate, targetDateSessions); + aggregateStart.Stop(); _logger.LogInformation($"Aggregated metrics for {targetDateSessions.Count} sessions from {targetDateStr} in {aggregateStart.ElapsedMilliseconds}ms"); } @@ -256,6 +259,68 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Reconciles rule stats for a given date by computing global aggregate rows + /// from per-tenant rows. This ensures consistency even if real-time global + /// increments were missed (e.g. during transient failures). + /// + private async Task AggregateRuleStatsForDateAsync(DateTime targetDate, List sessions) + { + try + { + var dateStr = targetDate.ToString("yyyy-MM-dd"); + // Fetch all tenant-specific rows for this date (excluding existing global rows) + var allEntries = await _metricsRepo.GetRuleStatsAsync(startDate: dateStr, endDate: dateStr); + var tenantEntries = allEntries.Where(e => e.TenantId != "global").ToList(); + + if (tenantEntries.Count == 0) + { + _logger.LogInformation("No tenant rule stats for {Date}, skipping rule stats aggregation", dateStr); + return; + } + + // Group by RuleId to compute global aggregates + var groups = tenantEntries.GroupBy(e => e.RuleId); + int written = 0; + + foreach (var group in groups) + { + var first = group.First(); + var totalFire = group.Sum(e => e.FireCount); + var totalEval = group.Sum(e => e.EvaluationCount); + var totalSessions = group.Sum(e => e.SessionsEvaluated); + var totalConfSum = group.Sum(e => e.ConfidenceScoreSum); + + var globalEntry = new RuleStatsEntry + { + Date = dateStr, + TenantId = "global", + RuleId = first.RuleId, + RuleType = first.RuleType, + RuleTitle = first.RuleTitle, + Category = first.Category, + Severity = first.Severity, + FireCount = totalFire, + EvaluationCount = totalEval, + SessionsEvaluated = totalSessions, + ConfidenceScoreSum = totalConfSum, + AvgConfidenceScore = totalFire > 0 ? Math.Round((double)totalConfSum / totalFire, 1) : 0, + UpdatedAt = DateTime.UtcNow + }; + + await _metricsRepo.SaveRuleStatsEntryAsync(globalEntry); + written++; + } + + _logger.LogInformation("Rule stats aggregation for {Date}: reconciled {Count} global rule entries from {TenantEntries} tenant entries", + dateStr, written, tenantEntries.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to aggregate rule stats for {Date} (non-fatal)", targetDate.ToString("yyyy-MM-dd")); + } + } + /// /// Deletes usage tracking records older than 90 days from UserUsageLog. /// @@ -273,6 +338,20 @@ namespace AutopilotMonitor.Functions.Services { _logger.LogWarning(ex, "Failed to cleanup old usage data"); } + + // Rule stats retention: delete entries older than 90 days + try + { + var ruleStatsCutoff = DateTime.UtcNow.AddDays(-90); + var deletedRuleStats = await _metricsRepo.DeleteRuleStatsOlderThanAsync(ruleStatsCutoff); + + if (deletedRuleStats > 0) + _logger.LogInformation("Rule stats cleanup: deleted {Count} entries older than 90 days", deletedRuleStats); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cleanup old rule stats"); + } } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs index b47350d9..0bc8fa59 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs @@ -29,10 +29,11 @@ namespace AutopilotMonitor.Functions.Services /// Evaluates ALL active analyze rules against the full session event stream. /// Called once at enrollment end or on-demand. Fetches events internally. /// When reanalyze=true, all rules are re-evaluated regardless of existing results. + /// Returns both the fired results and metadata about all rules that were evaluated (for telemetry). /// - public async Task> AnalyzeSessionAsync(string tenantId, string sessionId, bool reanalyze = false) + public async Task AnalyzeSessionAsync(string tenantId, string sessionId, bool reanalyze = false) { - var results = new List(); + var outcome = new AnalysisOutcome(); try { @@ -42,7 +43,7 @@ namespace AutopilotMonitor.Functions.Services if (allEvents.Count == 0) { _logger.LogInformation($"No events found for session {sessionId}, skipping analysis"); - return results; + return outcome; } // On reanalyze: skip deduplication so all rules are re-evaluated from scratch @@ -68,12 +69,15 @@ namespace AutopilotMonitor.Functions.Services if (existingRuleIds.Contains(rule.RuleId)) continue; + // Track that this rule was evaluated (for telemetry) + outcome.EvaluatedRules.Add(rule); + var result = EvaluateRule(rule, allEvents); if (result != null) { result.SessionId = sessionId; result.TenantId = tenantId; - results.Add(result); + outcome.Results.Add(result); _logger.LogInformation($"Rule {rule.RuleId} ({rule.Trigger}) fired for session {sessionId} with confidence {result.ConfidenceScore}%"); } } @@ -88,7 +92,7 @@ namespace AutopilotMonitor.Functions.Services _logger.LogError(ex, "Error analyzing session"); } - return results; + return outcome; } /// @@ -161,4 +165,16 @@ namespace AutopilotMonitor.Functions.Services /// Evaluates a single condition against the event stream /// } + + /// + /// Return type for AnalyzeSessionAsync — includes both fired results and evaluation metadata for telemetry. + /// + public class AnalysisOutcome + { + /// Rules that fired (produced a result) + public List Results { get; set; } = new List(); + + /// All rules that were evaluated in this pass (includes rules that didn't fire) + public List EvaluatedRules { get; set; } = new List(); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index 44279c31..467e771f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -688,5 +688,220 @@ namespace AutopilotMonitor.Functions.Services return (0, 0); } } + + // ===== RULE STATS METHODS ===== + + /// + /// Increments rule stats counters atomically for a single rule evaluation. + /// Creates the row if it doesn't exist. Uses read-modify-write pattern. + /// Called once per rule per session evaluation (for both tenant-specific and global rows). + /// + public async Task IncrementRuleStatAsync( + string date, string tenantId, string ruleId, string ruleType, + string ruleTitle, string category, string severity, + bool fired, int? confidenceScore) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStats); + var rowKey = $"{tenantId}_{ruleId}"; + + TableEntity entity; + try + { + var response = await tableClient.GetEntityAsync(date, rowKey); + entity = response.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + entity = new TableEntity(date, rowKey) + { + ["RuleId"] = ruleId, + ["RuleType"] = ruleType, + ["RuleTitle"] = ruleTitle, + ["Category"] = category, + ["Severity"] = severity, + ["FireCount"] = 0, + ["EvaluationCount"] = 0, + ["SessionsEvaluated"] = 0, + ["ConfidenceScoreSum"] = 0L, + ["AvgConfidenceScore"] = 0.0, + ["UpdatedAt"] = DateTime.UtcNow + }; + } + + // Always increment evaluation and session counters + entity["EvaluationCount"] = (entity.GetInt32("EvaluationCount") ?? 0) + 1; + entity["SessionsEvaluated"] = (entity.GetInt32("SessionsEvaluated") ?? 0) + 1; + + if (fired) + { + var newFireCount = (entity.GetInt32("FireCount") ?? 0) + 1; + entity["FireCount"] = newFireCount; + + if (confidenceScore.HasValue) + { + var newSum = (entity.GetInt64("ConfidenceScoreSum") ?? 0) + confidenceScore.Value; + entity["ConfidenceScoreSum"] = newSum; + entity["AvgConfidenceScore"] = newFireCount > 0 ? (double)newSum / newFireCount : 0.0; + } + } + + // Keep metadata fresh + entity["RuleTitle"] = ruleTitle; + entity["Category"] = category; + entity["Severity"] = severity; + entity["UpdatedAt"] = DateTime.UtcNow; + + await tableClient.UpsertEntityAsync(entity); + } + catch (Exception ex) + { + // Non-fatal: don't break the caller if stats update fails + _logger.LogWarning(ex, "Failed to increment rule stat for {RuleId} / {TenantId}", ruleId, tenantId); + } + } + + /// + /// Saves a fully computed rule stats entry (used by daily aggregation). + /// + public async Task SaveRuleStatsEntryAsync(RuleStatsEntry entry) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStats); + var rowKey = $"{entry.TenantId}_{entry.RuleId}"; + + var entity = new TableEntity(entry.Date, rowKey) + { + ["RuleId"] = entry.RuleId, + ["RuleType"] = entry.RuleType, + ["RuleTitle"] = entry.RuleTitle, + ["Category"] = entry.Category, + ["Severity"] = entry.Severity, + ["FireCount"] = entry.FireCount, + ["EvaluationCount"] = entry.EvaluationCount, + ["SessionsEvaluated"] = entry.SessionsEvaluated, + ["AvgConfidenceScore"] = entry.AvgConfidenceScore, + ["ConfidenceScoreSum"] = entry.ConfidenceScoreSum, + ["UpdatedAt"] = entry.UpdatedAt + }; + + await tableClient.UpsertEntityAsync(entity); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save rule stats entry for {RuleId} / {TenantId} / {Date}", + entry.RuleId, entry.TenantId, entry.Date); + return false; + } + } + + /// + /// Gets rule stats entries for a date range, optionally filtered by tenant and/or rule type. + /// + public async Task> GetRuleStatsAsync( + string? tenantId = null, string? startDate = null, string? endDate = null, + string? ruleType = null, int maxResults = 500) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStats); + + var filters = new List(); + + if (!string.IsNullOrEmpty(startDate)) + filters.Add($"PartitionKey ge '{startDate}'"); + if (!string.IsNullOrEmpty(endDate)) + filters.Add($"PartitionKey le '{endDate}'"); + + // Filter by tenant via RowKey prefix + if (!string.IsNullOrEmpty(tenantId)) + { + filters.Add($"RowKey ge '{tenantId}_'"); + filters.Add($"RowKey lt '{tenantId}_~'"); // ~ is after all printable ASCII + } + + if (!string.IsNullOrEmpty(ruleType)) + filters.Add($"RuleType eq '{ruleType}'"); + + var filter = filters.Count > 0 ? string.Join(" and ", filters) : null; + var query = tableClient.QueryAsync(filter: filter); + + var results = new List(); + await foreach (var entity in query) + { + results.Add(MapToRuleStatsEntry(entity)); + if (results.Count >= maxResults) break; + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get rule stats"); + return new List(); + } + } + + /// + /// Deletes rule stats entries older than a given date (retention cleanup). + /// + public async Task DeleteRuleStatsOlderThanAsync(DateTime cutoffDate) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStats); + var cutoffStr = cutoffDate.ToString("yyyy-MM-dd"); + var filter = $"PartitionKey lt '{cutoffStr}'"; + var query = tableClient.QueryAsync(filter: filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + await foreach (var entity in query) + { + await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey); + deleted++; + } + + if (deleted > 0) + _logger.LogInformation("Deleted {Count} rule stats entries older than {Cutoff}", deleted, cutoffStr); + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete old rule stats entries"); + return 0; + } + } + + private static RuleStatsEntry MapToRuleStatsEntry(TableEntity entity) + { + var rowKey = entity.RowKey ?? string.Empty; + var separatorIndex = rowKey.IndexOf('_'); + var tenantId = separatorIndex > 0 ? rowKey.Substring(0, separatorIndex) : rowKey; + // RuleId may contain underscores, so take everything after the first underscore + var ruleId = separatorIndex > 0 && separatorIndex < rowKey.Length - 1 + ? rowKey.Substring(separatorIndex + 1) + : entity.GetString("RuleId") ?? string.Empty; + + return new RuleStatsEntry + { + Date = entity.PartitionKey ?? string.Empty, + TenantId = tenantId, + RuleId = entity.GetString("RuleId") ?? ruleId, + RuleType = entity.GetString("RuleType") ?? string.Empty, + RuleTitle = entity.GetString("RuleTitle") ?? string.Empty, + Category = entity.GetString("Category") ?? string.Empty, + Severity = entity.GetString("Severity") ?? string.Empty, + FireCount = entity.GetInt32("FireCount") ?? 0, + EvaluationCount = entity.GetInt32("EvaluationCount") ?? 0, + SessionsEvaluated = entity.GetInt32("SessionsEvaluated") ?? 0, + AvgConfidenceScore = entity.GetDouble("AvgConfidenceScore") ?? 0, + ConfidenceScoreSum = entity.GetInt64("ConfidenceScoreSum") ?? 0, + UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow + }; + } } } diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 59650e1b..2a84c86d 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -396,4 +396,36 @@ export function registerAdminTools(server: McpServer): void { } } ); + + // Tool: get_rule_stats + server.tool( + 'get_rule_stats', + 'Get rule firing statistics for analyze and gather rules. Shows which rules fire most often, ' + + 'their hit rates (fires/evaluations), and daily trends. Use to identify commonly triggered rules, ' + + 'optimize rule definitions, or understand tenant-specific failure patterns. ' + + 'Without tenantId returns global stats (cross-tenant). With tenantId returns tenant-specific stats.', + { + tenantId: z.string().optional().describe('Filter by tenant ID. Omit for global (cross-tenant) stats.'), + ruleType: z.enum(['analyze', 'gather']).optional().describe('Filter by rule type'), + startDate: z.string().optional().describe('Start date (YYYY-MM-DD). Defaults to 30 days ago.'), + endDate: z.string().optional().describe('End date (YYYY-MM-DD). Defaults to today.'), + }, + READ_ONLY, + async (args) => { + try { + const params = buildQuery({ + startDate: args.startDate, + endDate: args.endDate, + ruleType: args.ruleType, + }); + const path = args.tenantId + ? `/api/metrics/rule-stats${params}` + : `/api/global/metrics/rule-stats${params}`; + const data = await apiFetch(path); + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; + } catch (error: unknown) { + return toolError('get_rule_stats', args, error); + } + } + ); } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index b1ed4fbd..61fa7cf9 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -260,6 +260,9 @@ namespace AutopilotMonitor.Shared public const string AppInstallSummaries = "AppInstallSummaries"; public const string PlatformStats = "PlatformStats"; + // Rule telemetry (daily per-rule fire/evaluation counters) + public const string RuleStats = "RuleStats"; + // Configuration tables public const string TenantConfiguration = "TenantConfiguration"; public const string AdminConfiguration = "AdminConfiguration"; @@ -353,7 +356,8 @@ namespace AutopilotMonitor.Shared DistressReports, UserUsageLog, OpsEvents, - ImeVersionHistory + ImeVersionHistory, + RuleStats }; } } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs index 3e84b003..39cc03bb 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs @@ -35,6 +35,14 @@ namespace AutopilotMonitor.Shared.DataAccess // --- Metrics Summary (Agent API) --- Task> GetMetricsSummaryAsync(string? tenantId); + + // --- Rule Stats --- + Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType, + string ruleTitle, string category, string severity, bool fired, int? confidenceScore); + Task SaveRuleStatsEntryAsync(RuleStatsEntry entry); + Task> GetRuleStatsAsync(string? tenantId = null, string? startDate = null, + string? endDate = null, string? ruleType = null, int maxResults = 500); + Task DeleteRuleStatsOlderThanAsync(DateTime cutoffDate); } public class UserActivityMetrics diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Metrics/RuleStatsEntry.cs b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/RuleStatsEntry.cs new file mode 100644 index 00000000..6b86e6ba --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Metrics/RuleStatsEntry.cs @@ -0,0 +1,79 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Daily per-rule telemetry entry. + /// Stored in Azure Table Storage for rule effectiveness analysis and tenant dashboards. + /// PartitionKey = Date (YYYY-MM-DD format) + /// RowKey = "{TenantId}_{RuleId}" (tenant-scoped) or "global_{RuleId}" (cross-tenant aggregate) + /// + public class RuleStatsEntry + { + /// + /// Date of the stats entry (YYYY-MM-DD) — maps to PartitionKey + /// + public string Date { get; set; } = string.Empty; + + /// + /// Tenant ID or "global" for cross-tenant — part of RowKey + /// + public string TenantId { get; set; } = string.Empty; + + /// + /// Rule identifier (e.g., "ANALYZE-NET-001" or "GATHER-REG-001") + /// + public string RuleId { get; set; } = string.Empty; + + /// + /// "analyze" or "gather" + /// + public string RuleType { get; set; } = string.Empty; + + /// + /// Human-readable rule title (denormalized for display) + /// + public string RuleTitle { get; set; } = string.Empty; + + /// + /// Rule category (network, identity, enrollment, apps, esp, device) + /// + public string Category { get; set; } = string.Empty; + + /// + /// Rule severity (info, warning, high, critical) + /// + public string Severity { get; set; } = string.Empty; + + /// + /// Number of times this rule fired (produced a result/event) on this day + /// + public int FireCount { get; set; } + + /// + /// Number of times this rule was evaluated on this day (analyze rules only; 0 for gather rules) + /// + public int EvaluationCount { get; set; } + + /// + /// Number of distinct sessions where this rule was evaluated + /// + public int SessionsEvaluated { get; set; } + + /// + /// Average confidence score when the rule fired (analyze rules only; 0 for gather rules) + /// + public double AvgConfidenceScore { get; set; } + + /// + /// Sum of confidence scores — used for incremental average computation. + /// AvgConfidenceScore = ConfidenceScoreSum / FireCount + /// + public long ConfidenceScoreSum { get; set; } + + /// + /// When this entry was last updated + /// + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx index 4fcae474..ea6b3187 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx @@ -34,6 +34,8 @@ interface AnalyzeRuleCardProps { templateCopyExists?: boolean; templateCopyRuleId?: string; onScrollToCopy?: (ruleId: string) => void; + hitRate?: number | null; + fireCount?: number | null; } export default function AnalyzeRuleCard({ @@ -45,6 +47,7 @@ export default function AnalyzeRuleCard({ onSetJsonModeEdit, onSetJsonText, onSetJsonError, readOnly = false, onConfigureTemplate, templateCopyExists, templateCopyRuleId, onScrollToCopy, + hitRate, fireCount, }: AnalyzeRuleCardProps) { const [showJson, setShowJson] = useState(false); const sevColor = getSeverityColor(rule.severity); @@ -104,6 +107,18 @@ export default function AnalyzeRuleCard({ )} Threshold: {rule.confidenceThreshold}% + {hitRate != null && hitRate > 0 && ( + = 20 ? "bg-red-50 text-red-700 border border-red-200" : + hitRate >= 5 ? "bg-amber-50 text-amber-700 border border-amber-200" : + "bg-gray-50 text-gray-600 border border-gray-200" + }`} + title={`Fires on ${hitRate}% of evaluated sessions (${fireCount ?? 0} total fires in last 30 days)`} + > + {hitRate}% hit rate + + )} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx index 775328f7..25bab436 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx @@ -76,6 +76,9 @@ export default function AnalyzeRulesPage() { const [configureTemplateRule, setConfigureTemplateRule] = useState(null); const [creatingFromTemplate, setCreatingFromTemplate] = useState(false); + // Rule telemetry stats (hit rates) + const [ruleStatsMap, setRuleStatsMap] = useState>({}); + // Global admin mode const { globalAdminMode } = useAdminMode(); const [tenants, setTenants] = useState([]); @@ -132,6 +135,32 @@ export default function AnalyzeRulesPage() { fetchRules(); }, [fetchRules]); + // Fetch rule telemetry stats (hit rates for last 30 days) + useEffect(() => { + if (!effectiveTenantId) return; + const fetchStats = async () => { + try { + const statsUrl = globalAdminMode && user?.isGlobalAdmin + ? api.metrics.globalRuleStats(undefined, undefined, "analyze") + : api.metrics.ruleStats(undefined, undefined, "analyze"); + const response = await authenticatedFetch(statsUrl, getAccessToken); + if (response.ok) { + const data = await response.json(); + const map: Record = {}; + if (data.rules && Array.isArray(data.rules)) { + for (const r of data.rules) { + map[r.ruleId] = { hitRate: r.hitRate ?? 0, fireCount: r.fireCount ?? 0 }; + } + } + setRuleStatsMap(map); + } + } catch { + // Non-critical: don't break the page if stats fail to load + } + }; + fetchStats(); + }, [effectiveTenantId, globalAdminMode, user?.isGlobalAdmin, getAccessToken]); + // Toggle rule enabled/disabled const handleToggleRule = async (rule: AnalyzeRule) => { // Intercept: if this is a template rule being enabled, open the config modal instead @@ -456,6 +485,25 @@ export default function AnalyzeRulesPage() { + {/* Telemetry Stats (only shown when data is available) */} + {Object.keys(ruleStatsMap).length > 0 && (() => { + const stats = Object.values(ruleStatsMap); + const totalFires = stats.reduce((sum, s) => sum + s.fireCount, 0); + const rulesWithHits = stats.filter(s => s.fireCount > 0).length; + const highHitRules = stats.filter(s => s.hitRate >= 20).length; + const avgHitRate = stats.length > 0 + ? Math.round(stats.reduce((sum, s) => sum + s.hitRate, 0) / stats.length * 10) / 10 + : 0; + return ( +
+ + + + +
+ ); + })()} + {/* Filter Bar + Create Button */} setExpandedRuleId(copyId)} + hitRate={ruleStatsMap[rule.ruleId]?.hitRate ?? null} + fireCount={ruleStatsMap[rule.ruleId]?.fireCount ?? null} /> ))} diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx index cb5d3632..16260a92 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/AnalysisResultsSection.tsx @@ -1,8 +1,12 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { RuleResult } from "@/types"; import { formatInlineMarkdown } from "@/lib/formatInlineMarkdown"; +import { api } from "@/lib/api"; +import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTenant } from "@/contexts/TenantContext"; interface AnalysisResultsSectionProps { analysisResults: RuleResult[]; @@ -19,6 +23,35 @@ export default function AnalysisResultsSection({ setAnalysisExpanded, onReanalyze, }: AnalysisResultsSectionProps) { + const { getAccessToken } = useAuth(); + const { tenantId } = useTenant(); + const [ruleHitRates, setRuleHitRates] = useState>({}); + + useEffect(() => { + if (!tenantId || analysisResults.length === 0) return; + const fetchStats = async () => { + try { + const response = await authenticatedFetch( + api.metrics.ruleStats(undefined, undefined, "analyze"), + getAccessToken + ); + if (response.ok) { + const data = await response.json(); + const map: Record = {}; + if (data.rules && Array.isArray(data.rules)) { + for (const r of data.rules) { + if (r.hitRate > 0) map[r.ruleId] = r.hitRate; + } + } + setRuleHitRates(map); + } + } catch { + // Non-critical + } + }; + fetchStats(); + }, [tenantId, analysisResults.length, getAccessToken]); + return (
{analysisResults.map((result) => ( - + ))}
)} @@ -104,7 +137,7 @@ export default function AnalysisResultsSection({ ); } -function AnalysisResultCard({ result }: { result: RuleResult }) { +function AnalysisResultCard({ result, hitRate }: { result: RuleResult; hitRate?: number }) { const [expanded, setExpanded] = useState(false); const severityColors: Record = { @@ -151,6 +184,11 @@ function AnalysisResultCard({ result }: { result: RuleResult }) {
{result.confidenceScore}% + {hitRate != null && hitRate > 0 && ( + + Fires on {hitRate}% of enrollments in your tenant + + )} diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index e51bce9c..6aa4f3f2 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -145,6 +145,10 @@ export const api = { globalGeographicSessions: (days: number, groupBy: string, locationKey: string) => `${API_BASE_URL}/api/global/metrics/geographic/sessions${qs({ days: String(days), groupBy, locationKey })}`, platform: () => `${API_BASE_URL}/api/global/metrics/platform`, + ruleStats: (startDate?: string, endDate?: string, ruleType?: string) => + `${API_BASE_URL}/api/metrics/rule-stats${qs({ startDate, endDate, ruleType })}`, + globalRuleStats: (startDate?: string, endDate?: string, ruleType?: string) => + `${API_BASE_URL}/api/global/metrics/rule-stats${qs({ startDate, endDate, ruleType })}`, }, // ── Apps Dashboard ────────────────────────────────────────────────────────