Files
AutopilotMonitor/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs
T
Oliver Kieselbach c287cb3eae Backend: retention and device-history reads fail closed (D-338)
- The retention fanout reads the tenant configuration strictly. A storage
  failure used to yield the synthesized default (90 days), which deletes
  sessions and prunes rows a Pro-365 or never-delete tenant keeps. Now the
  tenant is skipped and counted: tenantsFailed on
  SessionDeletionMaintenanceCompleted, which turns Warning. A row that
  vanished since the tenant listing is skipped too, instead of being
  recreated with defaults.
- GetDeviceHistoryAsync throws on a read failure instead of returning "no
  history". Both writers merge into the chain they read and replace the row,
  so the old behaviour dropped every older ref. The sweep skips such devices
  and leaves the FTR aggregates untouched for that tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:33:29 +02:00

235 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Repository for usage metrics, platform stats, and user activity tracking.
/// Covers: UsageMetrics, PlatformStats, UserActivity, AppInstallSummaries tables.
/// </summary>
public interface IMetricsRepository
{
// --- Usage Metrics Snapshots ---
Task<bool> SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics);
Task<List<UsageMetricsSnapshot>> GetUsageMetricsSnapshotAsync(
string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100);
Task<bool> HasUsageMetricsSnapshotAsync(string date);
/// <summary>Retention cleanup: deletes UsageMetrics snapshots whose date (PK, "yyyy-MM-dd") is older than the cutoff. Returns the number deleted.</summary>
Task<int> DeleteUsageMetricsSnapshotsOlderThanAsync(string cutoffDate);
// --- App Install Summaries ---
Task<bool> StoreAppInstallSummaryAsync(AppInstallSummary summary);
/// <summary>Gets a tenant's app install summaries. Pass <paramref name="sinceUtc"/> to push the window's <c>StartedAt ge</c> filter server-side; null returns the full partition (legacy behaviour).</summary>
Task<List<AppInstallSummary>> GetAppInstallSummariesByTenantAsync(string tenantId, DateTime? sinceUtc = null);
/// <summary>Gets all tenants' app install summaries. Pass <paramref name="sinceUtc"/> to scope the (otherwise full-table) scan to the window; null returns everything (legacy behaviour).</summary>
Task<List<AppInstallSummary>> GetAllAppInstallSummariesAsync(DateTime? sinceUtc = null);
/// <summary>
/// Lean (SessionId, AppName) pairs for app-per-session aggregation. Same window/tenant scoping
/// as the summary getters above, but column-projected server-side — the usage-metrics compute
/// only groups by SessionId and counts distinct AppNames, so shipping the full summary rows
/// (DO telemetry, failure text, …) multiplied transfer for nothing. Omit <paramref name="tenantId"/>
/// for the cross-tenant scan.
/// </summary>
Task<List<SessionAppRef>> GetAppInstallRefsAsync(DateTime sinceUtc, string? tenantId = null);
/// <summary>
/// Column-projected windowed scan for the geographic endpoints: session join key, window
/// filter column, download throughput inputs and the Delivery Optimization counters. The
/// returned <see cref="AppInstallSummary"/> objects carry ONLY those fields — everything
/// else is defaults and must not be read. Omit <paramref name="tenantId"/> for the
/// cross-tenant scan.
/// </summary>
Task<List<AppInstallSummary>> GetGeoAppInstallSummariesAsync(DateTime sinceUtc, string? tenantId = null);
/// <summary>
/// Column-projected windowed scan for the app-metrics endpoints (slowest/failing ranking +
/// DO rollup): grouping key, status/duration/bytes, failure code and the DO counters. The
/// returned objects carry ONLY those fields — everything else is defaults and must not be
/// read. Omit <paramref name="tenantId"/> for the cross-tenant scan.
/// </summary>
Task<List<AppInstallSummary>> GetAppMetricsSummariesAsync(DateTime sinceUtc, string? tenantId = null);
/// <summary>
/// Column-projected windowed scan for the App Dashboard endpoints (list / analytics /
/// sessions): everything those aggregations read, WITHOUT the Delivery Optimization
/// telemetry block. The returned objects carry ONLY the projected fields — everything else
/// is defaults and must not be read. Omit <paramref name="tenantId"/> for the cross-tenant scan.
/// </summary>
Task<List<AppInstallSummary>> GetAppsDashboardSummariesAsync(DateTime sinceUtc, string? tenantId = null);
// --- Platform Stats ---
Task<PlatformStats?> GetPlatformStatsAsync();
/// <summary>
/// Writes the platform row and the rollup baseline in one ETag-bound transaction.
/// <paramref name="merge"/> receives the row and the baseline as read (either may be null)
/// and returns the new row; <paramref name="sources"/> becomes the next baseline. The write
/// never carries <c>IssuesDetected</c> (its increments own that field), and a concurrent
/// change of the row re-reads and re-merges. Returns the row as persisted, null on failure.
/// </summary>
Task<PlatformStats?> RollupPlatformStatsAsync(
Func<PlatformStats?, PlatformRollupSources?, PlatformStats> merge, PlatformRollupSources sources);
Task IncrementPlatformStatAsync(string field, long amount = 1);
/// <summary>
/// Adds the given device models to the persisted set of models ever seen (never pruned, so
/// it survives session retention) and returns the size of the set.
/// </summary>
Task<long> RecordSeenDeviceModelsAsync(IReadOnlyCollection<string> models);
// --- Tenant Stats (cumulative per-tenant counters, retention-independent) ---
/// <summary>Gets the cumulative per-tenant counters, or null if none were recorded yet.</summary>
Task<TenantStats?> GetTenantStatsAsync(string tenantId);
/// <summary>Gets the cumulative counters of every tenant that has a row — the source of the platform rollup.</summary>
Task<List<TenantStats>> GetAllTenantStatsAsync();
/// <summary>Increments a cumulative per-tenant counter (ETag CAS with retries; fail-soft).</summary>
Task IncrementTenantStatAsync(string tenantId, string field, long amount = 1);
/// <summary>
/// Raises cumulative per-tenant counters to at least the given floors in one write (seed/self-heal).
/// Never lowers one. Returns false when the floors could not be put in place.
/// </summary>
Task<bool> EnsureTenantStatFloorsAsync(string tenantId, IReadOnlyDictionary<string, long> floors);
// --- User Activity ---
Task RecordUserLoginAsync(string tenantId, string upn, string? displayName, string? objectId);
Task<UserActivityMetrics> GetUserActivityMetricsAsync(string tenantId);
Task<UserActivityMetrics> GetAllUserActivityMetricsAsync();
Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date);
/// <summary>Retention cleanup: deletes UserActivity login rows whose LoginAt is older than the cutoff. Returns the number deleted.</summary>
Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc);
/// <summary>
/// Every distinct (tenant id, object id) a UPN has signed in with, from the UserActivity login rows
/// (cross-partition scan filtered on Upn — grant-time only, never a hot path). Both values came from
/// validated tokens, which is what makes them usable as an identity-binding source.
/// </summary>
Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn);
// --- Live Presence (one row per user, upserted) ---
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
Task RecordUserPresenceAsync(string tenantId, string upn, string userRole);
/// <summary>Gets the caller's per-channel What's-new seen marks from the presence row; nulls mean never or unavailable.</summary>
Task<UserWhatsNewSeen> GetUserWhatsNewSeenAsync(string tenantId, string upn);
/// <summary>Monotonically merge-upserts the caller's What's-new seen mark for one channel without touching presence fields.</summary>
Task MarkUserWhatsNewSeenAsync(string tenantId, string upn, string channel, DateTime seenUtc);
/// <summary>Returns all users whose LastSeen falls within the given window, newest first (cross-tenant).</summary>
Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window);
/// <summary>Retention cleanup: deletes presence rows whose LastSeen is older than the cutoff (drops one-off testers). Returns the number deleted.</summary>
Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc);
// --- Metrics Summary (Agent API) ---
Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30);
// --- Rule Stats (layout D-199: PartitionKey "{scope}_{date}", RowKey = ruleId) ---
/// <summary>
/// Folds a session's rule evaluations into one scope's daily counters (scope = tenant id or
/// "global"): one partition read + one CAS transaction, increments per rule merged first.
/// </summary>
Task RecordRuleStatsAsync(string date, string scope, IReadOnlyList<RuleStatIncrement> increments);
Task<bool> SaveRuleStatsEntryAsync(RuleStatsEntry entry);
/// <summary>
/// Folds one session's IME pattern histogram (patternId → matches, zeros included) into
/// the global per-version statistics: every listed pattern gets Sessions+1, matched ones
/// SessionsWithHit+1 and Hits+count. One partition read + one transactional batch.
/// </summary>
Task UpsertImePatternStatsAsync(string imeVersion, IReadOnlyDictionary<string, int> hits, DateTime nowUtc);
/// <summary>All ImePatternStats rows across versions (lean projection).</summary>
Task<List<ImePatternStatsEntry>> GetImePatternStatsAsync();
/// <summary>Stamps DriftFlaggedAt on one version×pattern cell; returns false when it was already stamped (lost race).</summary>
Task<bool> TryMarkImePatternDriftFlaggedAsync(string imeVersion, string patternId, DateTime nowUtc);
/// <summary>
/// One scope (tenant id or "global"), a date range, optionally one rule type — a
/// PartitionKey range read. maxResults is a runaway backstop, not a page size: a 31-day
/// window easily holds 1400+ rows (rules × days) for one scope.
/// </summary>
Task<List<RuleStatsEntry>> GetRuleStatsAsync(string tenantId, string? startDate = null,
string? endDate = null, string? ruleType = null, int maxResults = 10000);
/// <summary>Many tenants, one range read each with bounded concurrency; never includes "global".</summary>
Task<List<RuleStatsEntry>> GetRuleStatsForTenantsAsync(IReadOnlyCollection<string> tenantIds, string? startDate = null,
string? endDate = null, string? ruleType = null, int maxResultsPerTenant = 10000);
/// <summary>
/// Retention cleanup across scopes: tenant partitions older than <paramref name="tenantCutoffDate"/> (their
/// ceiling; each tenant's own retention is applied by <see cref="DeleteTenantRuleStatsOlderThanAsync"/>), "global"
/// and the legacy layout older than <paramref name="platformCutoffDate"/>. Returns the number of rows deleted.
/// </summary>
Task<int> DeleteRuleStatsOlderThanAsync(DateTime tenantCutoffDate, DateTime platformCutoffDate, IReadOnlyCollection<string> tenantIds);
/// <summary>One tenant's partitions older than the cutoff (tenant retention); returns the number of rows deleted.</summary>
Task<int> DeleteTenantRuleStatsOlderThanAsync(string tenantId, DateTime cutoffDate);
// --- F1 Time Attribution (insights spec §F1, PR2) ---
/// <summary>Computes + stores the session's time breakdown; null when not computable (non-terminal, no duration, events aged out) — fail-soft.</summary>
Task<SessionTimeBreakdown?> ComputeAndStoreSessionTimeBreakdownAsync(string tenantId, string sessionId);
Task<SessionTimeBreakdown?> GetSessionTimeBreakdownAsync(string tenantId, string sessionId);
/// <summary>Re-joins the session's app rows against its latest esp_config_detected lists (positive evidence only). Idempotent + fail-soft — the terminal seam runs it once; the sweep re-runs it when late events changed the stream.</summary>
Task ResolveEspBlockingForSessionAsync(string tenantId, string sessionId);
/// <summary>Closes the session's app rows that never reached a terminal state (InProgress → Incomplete, outcome unknown). Idempotent + fail-soft — the terminal seam, the agent-shutdown batch and the sweep all call it; a real terminal arriving later still wins. Returns the number of rows closed.</summary>
Task<int> CloseOpenAppInstallsForSessionAsync(string tenantId, string sessionId);
Task<bool> SaveTimeAttributionAggregateAsync(TimeAttributionDailyAggregate aggregate);
/// <summary>Aggregate rows of one tenant partition ("global" allowed) for an inclusive date range.</summary>
Task<List<TimeAttributionDailyAggregate>> GetTimeAttributionAggregatesAsync(string tenantId, DateTime startDate, DateTime endDate);
/// <summary>Rolling-window rows (Date "rolling30") of one tenant partition ("global" allowed) — the fleet panel's range statistics.</summary>
Task<List<TimeAttributionDailyAggregate>> GetRollingTimeAttributionAggregatesAsync(string tenantId);
/// <summary>Deletes one aggregate row (daily date key or "rolling30") — the sweep's stale-bucket reconcile.</summary>
Task DeleteTimeAttributionAggregateAsync(string tenantId, string dateKey, string enrollmentClass);
Task<int> DeleteTimeAttributionAggregatesOlderThanAsync(DateTime cutoffDate);
/// <summary>One tenant's daily rows older than the cutoff (tenant retention); its rolling rows stay.</summary>
Task<int> DeleteTenantTimeAttributionAggregatesOlderThanAsync(string tenantId, DateTime cutoffDate);
// --- F2 Device History / First-Time-Right (insights spec §F2, PR4) ---
/// <summary>Point-reads one device's history row by NORMALIZED serial; null when absent, throws when the read fails.</summary>
Task<DeviceHistory?> GetDeviceHistoryAsync(string tenantId, string serialKey);
/// <summary>All device-history rows of one tenant (sweep's tombstone-cleanup scan).</summary>
Task<List<DeviceHistory>> GetDeviceHistoriesByTenantAsync(string tenantId);
Task<bool> UpsertDeviceHistoryAsync(DeviceHistory history);
/// <summary>Deletes a device's history row (used when every chain ref belonged to deleted sessions).</summary>
Task DeleteDeviceHistoryAsync(string tenantId, string serialKey);
Task<bool> SaveDeviceJourneyAggregateAsync(DeviceJourneyDailyAggregate aggregate);
/// <summary>Daily FTR rows of one tenant partition ("global" allowed), inclusive date range; counts are additive across days.</summary>
Task<List<DeviceJourneyDailyAggregate>> GetDeviceJourneyAggregatesAsync(string tenantId, DateTime startDate, DateTime endDate);
/// <summary>Deletes one daily FTR row — the sweep's stale-bucket reconcile.</summary>
Task DeleteDeviceJourneyAggregateAsync(string tenantId, string dateKey);
Task<int> DeleteDeviceJourneyAggregatesOlderThanAsync(DateTime cutoffDate);
/// <summary>One tenant's daily FTR rows older than the cutoff (tenant retention).</summary>
Task<int> DeleteTenantDeviceJourneyAggregatesOlderThanAsync(string tenantId, DateTime cutoffDate);
// --- Verdict calibration (internal/docs/backend/verdict-calibration.md) ---
Task<bool> SaveVerdictCalibrationAggregateAsync(VerdictCalibrationDailyAggregate aggregate);
/// <summary>Daily verdict-path rows of one tenant partition ("global" allowed), inclusive date range; counts are additive across days.</summary>
Task<List<VerdictCalibrationDailyAggregate>> GetVerdictCalibrationAggregatesAsync(string tenantId, DateTime startDate, DateTime endDate);
/// <summary>Deletes one daily row — the sweep's stale-bucket reconcile.</summary>
Task DeleteVerdictCalibrationAggregateAsync(string tenantId, string dateKey);
Task<int> DeleteVerdictCalibrationAggregatesOlderThanAsync(DateTime cutoffDate);
}
/// <summary>
/// Minimal projection of an AppInstallSummaries row: just enough to group installs per session
/// and count distinct app names. Returned by <see cref="IMetricsRepository.GetAppInstallRefsAsync"/>.
/// </summary>
public class SessionAppRef
{
public string SessionId { get; set; } = string.Empty;
public string AppName { get; set; } = string.Empty;
}
/// <summary>One (tenant, object id) a UPN was seen signing in with, plus the most recent such sign-in.</summary>
public class UserSignInIdentity
{
public string TenantId { get; set; } = string.Empty;
public string ObjectId { get; set; } = string.Empty;
public DateTime LastLoginAt { get; set; }
public int LoginCount { get; set; }
}
public class UserActivityMetrics
{
/// <summary>
/// Distinct users with a login row in the UserActivity table. NOTE: that table is pruned to the
/// retention window (90 days), so this is "unique users within retention", NOT an all-time total.
/// The cumulative all-time figure is the monotonic high-water-mark in PlatformStats.TotalUsers.
/// </summary>
public int TotalUniqueUsers { get; set; }
public int DailyLogins { get; set; }
public int ActiveUsersLast7Days { get; set; }
public int ActiveUsersLast30Days { get; set; }
}
}