Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleStatsFunction.cs
T
Oliver Kieselbach 51737174b2 feat(backend,mcp): typed API contract COMPLETED — every remaining top-level success body is a manifest-exported wire DTO, and the guard now catches the bypass shapes that let them escape. The 08-31 migration's ratchet only saw inline WriteAsJsonAsync(new {...}); four bypass classes had kept ~20 endpoint families untyped: (A) Shared classes without IApiResponse — GeographicMetricsResponse, FleetHealthMetrics, ImePatternHealthResponse, SlaMetricsResponse, PlatformUsageMetrics now implement it (wire byte-identical, interface is empty); (B) Functions-local named response families moved verbatim to Shared — PlatformAgentMetricsResponse/SessionAgentMetric/DeliveryLatencyMetrics/CrashRateMetrics/CrashExceptionSummary, AgentEfficiencyMetricsResponse family (CloneAsCacheHit relocated into the service; init→set for netstandard2.0), OffboardResponse; (C) anonymous/object builders converted to DTOs with old-vs-new wire-parity facts (TypedContractCompletionWireParityTests, 14 facts incl. every vanishing-key case): rule-stats x2 (RuleStatsResponse; uniqueRules is global-only via an optional slot at its exact wire position), verdict-calibration (builder renamed VerdictCalibrationResponseBuilder, PathRow/TrendWindow to Shared), metrics/app (MetricsMath), apps list/analytics/sessions x6 routes (AppsAnalyticsHelper; legacy + paged list share ONE DTO via optional paging keys), ime-versions lean projection (ImeVersionHistoryLeanEntry [WireContract]; bare-array envelope stays, typed as IReadOnlyList<object>), device-journeys + time-attribution fleet envelopes, feature-flags, diagnostics/paths, session restore, cascade-delete Enqueued arm (error arms stay anonymous by design), feedback eligibility/list; (D) Serialize+WriteString path — ListBackupsResponse under the backup surface's own serializer options; AppInsightsQuery's Serialize is an outgoing REQUEST body (not response debt, baselined as such). TypedResponseGuardTests gains three ratchets with mutation-proven red/green: WriteAsJsonAsync(<variable assigned an anonymous object>) (empty baseline), JsonSerializer.Serialize(new{ (error-tolerant; 1 documented request-body entry), and object/Task<object>-returning Build*/Compute*/*Payload/*Response methods (1 documented union entry); the local-WriteJson-wrapper class is closed structurally (generic T : IApiResponse — an interface-typed parameter would serialize zero properties, the generic keeps the concrete type). Manifest regenerated 208→298 exported types; MCP adopts the new types (get_rule_stats typed with the trend-drop as explicit output shaping, get_platform_metrics drops its hand-declared mirror for PlatformAgentMetricsResponse, verdict-calibration-shape derives its rows via extends Omit<generated wire row> so backend renames go tsc-red). Six backend shape-test files switched from default-options serialization to the production wire options via TestWire (ApiJsonOptions; the old default-options runs only matched while payloads were anonymous with lowercase members), and three assertions that pinned null-VALUED keys now pin key ABSENCE — production always omitted them (WhenWritingNull); the parity facts prove the wire itself is unchanged. Deliberately still untyped: auth/me, CheckMcpAccess dictionary, TenantAdmins/GlobalAdmins entity-row arrays and the object-slot payloads (RuleDryRun, HealthCheck, TenantConfigFieldSchema, ...) — the documented read-model follow-up. Suites: Backend 5323, DecisionCore 577, Agent 2805, SummaryDialog 44, MCP 617, Web tsc 0 + vitest 957, generator idempotent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:32:51 +02:00

176 lines
8.4 KiB
C#

using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Metrics
{
/// <summary>
/// Functions for retrieving rule telemetry stats.
/// Tenant-facing: per-tenant rule effectiveness metrics.
/// Global admin: cross-tenant rule stats and adoption summaries.
/// </summary>
public class RuleStatsFunction
{
private readonly ILogger<RuleStatsFunction> _logger;
private readonly IMetricsRepository _metricsRepo;
private readonly IHardwareRejectionNotificationTracker _notificationTracker;
public RuleStatsFunction(
ILogger<RuleStatsFunction> logger,
IMetricsRepository metricsRepo,
IHardwareRejectionNotificationTracker notificationTracker)
{
_logger = logger;
_metricsRepo = metricsRepo;
_notificationTracker = notificationTracker;
}
/// <summary>
/// GET /api/metrics/rule-stats?startDate=...&amp;endDate=...&amp;ruleType=analyze
/// Returns rule stats for the caller's tenant (MemberRead).
/// </summary>
[Function("GetRuleStats")]
public async Task<HttpResponseData> 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);
var aggregated = BuildRuleAggregates(entries);
var result = new RuleStatsResponse
{
Rules = aggregated,
// F3 PR6: active regression episodes (tracker rows) — the rules-page badge
// and MCP get_rule_stats read them from here. Empty list = nothing regressed.
Regressions = await _notificationTracker.GetRuleRegressionsAsync(tenantId),
// The tenant route historically carries no uniqueRules key — keep it absent.
Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: null),
};
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;
}
}
/// <summary>
/// GET /api/global/metrics/rule-stats?startDate=...&amp;endDate=...&amp;ruleType=analyze&amp;tenantId=...
/// Returns cross-tenant global rule stats (Global Admin only).
/// When tenantId is provided, returns tenant-specific stats instead of global aggregates.
/// </summary>
[Function("GetGlobalRuleStats")]
public async Task<HttpResponseData> 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"];
var tenantId = req.Query["tenantId"];
// If tenantId specified, return tenant-specific stats; otherwise global aggregates
var queryTenantId = !string.IsNullOrEmpty(tenantId) ? tenantId : "global";
_logger.LogInformation("Global rule stats requested for {Scope} ({StartDate} to {EndDate})",
queryTenantId, startDate, endDate);
var entries = await _metricsRepo.GetRuleStatsAsync(queryTenantId, startDate, endDate, ruleType);
var aggregated = BuildRuleAggregates(entries);
var result = new RuleStatsResponse
{
Rules = aggregated,
// Regression episodes are tenant-scoped: present when drilling into one
// tenant, empty for the cross-tenant "global" aggregate (no episode there).
Regressions = queryTenantId != "global"
? await _notificationTracker.GetRuleRegressionsAsync(queryTenantId)
: new List<RuleRegressionAlert>(),
Summary = BuildSummary(aggregated, startDate, endDate, uniqueRules: aggregated.Count),
};
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;
}
}
/// <summary>Aggregate the per-date entries across dates per ruleId, fires descending.</summary>
internal static List<RuleStatsRuleAggregate> BuildRuleAggregates(IReadOnlyList<RuleStatsEntry> entries)
=> entries
.GroupBy(e => e.RuleId)
.Select(g => new RuleStatsRuleAggregate
{
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 RuleTrendPoint
{
Date = e.Date,
FireCount = e.FireCount,
EvaluationCount = e.EvaluationCount
}).ToList(),
})
.OrderByDescending(r => r.FireCount)
.ToList();
internal static RuleStatsSummary BuildSummary(
IReadOnlyList<RuleStatsRuleAggregate> aggregated, string startDate, string endDate, int? uniqueRules)
{
var totalEvaluations = aggregated.Sum(r => r.EvaluationCount);
var totalFires = aggregated.Sum(r => r.FireCount);
return new RuleStatsSummary
{
TotalEvaluations = totalEvaluations,
TotalFires = totalFires,
OverallHitRate = totalEvaluations > 0
? Math.Round(100.0 * totalFires / totalEvaluations, 1)
: 0.0,
TopRuleByFireCount = aggregated.Count > 0 ? aggregated[0].RuleId : null,
UniqueRules = uniqueRules,
Period = new RuleStatsPeriod { Start = startDate, End = endDate },
};
}
}
}