Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs
T
Oliver Kieselbach 7842d121a0 fix(insights): codex review findings - WG pause clamping, late-event healing, stale buckets, date windows, banner count
All six review findings verified against code and real; five fixed here,
the agent-side race lands in the companion agent commit.

[P1] WhiteGlove pause counted as time: ClampToWindows clipped intervals
against the HULL of both WG windows, but the excluded pause lies inside
the hull - a reboot gap or app interval straddling the pause counted the
entire pause (a ~2-day "reboot" in a 30-minute enrollment was possible).
Seconds are now the per-window intersection sum (chronological endpoints
kept); an interval wholly inside the pause makes no claim and no longer
raises ClockSkewDropped. What-if savings are additionally capped at the
session's WallClockSeconds - endpoint deltas can still straddle the
pause, and no removal can save more than the enrollment took.

[P1] Late-arriving events never updated breakdowns: the terminal-seam
compute is one-shot and the sweep only healed missing/version-stale rows.
SessionTimeBreakdown now stores EventCountAtCompute (0 = pre-fix row,
recomputed once); the sweep recomputes on EventCount drift and then also
re-runs ResolveEspBlockingForSessionAsync (newly exposed on
IMetricsRepository) so late esp_config_detected re-emits and app batches
reach both the breakdown and the blocking evidence.

[P2] Stale aggregate buckets: the sweeps now reconcile every partition
they wrote - buckets that existed but were not regenerated (sessions
deleted, class left the window) are deleted via new targeted deletes;
rolling30 rows additionally get a 48h ComputedAt freshness filter at
read time, so a fully quiet tenant stops serving year-old numbers as
"last 30 days".

[P2] Date windows included an extra calendar day: both inclusive range
ends meant days=30 returned 31 keys and days=1 summed yesterday AND
today. InclusiveWindowStart(today, days) = today-(days-1) in both
response builders; the sweep's rolling30 source is filtered to the same
30 calendar days (its query window stays one day wider for backfill).

[P3] Device-history banner counted later sessions as "previous
enrollments" when viewing an older session: selectPriorSessions
(deviceHistoryPrior.ts) anchors on the session's own chain entry (live
sessions: new sessionStartedAt prop), sessionId tie-break mirroring the
server chain sort.

Verified: solution build clean, Backend 3480/3480 (+6: three WG-pause
golden fixtures, window-start facts, RK builder/delete consistency,
EventCountAtCompute roundtrip), Web tsc + vitest 633/633 (+6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 10:52:39 +02:00

326 lines
16 KiB
C#

using System.Text.Json;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Partial: F1 time-attribution persistence (insights spec §F1, PR2).
/// SessionTimeBreakdowns: PK=TenantId, RK=SessionId — one row per terminal session,
/// written once at the terminal transition and self-healed by the 30-day maintenance
/// sweep; deleted with its session (deletion manifest step + offboarding partition wipe).
/// TimeAttributionAggregates: PK=TenantId ("global" for the cross-tenant row),
/// RK="{yyyy-MM-dd}|{enrollmentClass}" — daily fleet rollups, recomputed idempotently.
/// </summary>
public partial class TableStorageService
{
// ===== SESSION TIME BREAKDOWNS =====
/// <summary>
/// Loads the session facts + events and computes/stores the breakdown via
/// <see cref="TimeAttributionCalculator"/>. Returns the stored breakdown, or null when
/// the session is not computable (non-terminal, Incomplete — which deliberately has no
/// DurationSeconds — or missing) or on any storage failure. Fail-soft by design: this
/// runs on the terminal-transition path and the maintenance sweep, and must never fail
/// either. Idempotent — recomputing from the same events yields the same row (the
/// calculator is deterministic and Replace-mode overwrites).
/// </summary>
public async Task<SessionTimeBreakdown?> ComputeAndStoreSessionTimeBreakdownAsync(string tenantId, string sessionId)
{
try
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
var session = await GetSessionAsync(tenantId, sessionId);
if (session == null) return null;
if (session.Status != SessionStatus.Succeeded && session.Status != SessionStatus.Failed) return null;
if (!session.DurationSeconds.HasValue || session.DurationSeconds.Value <= 0) return null;
var events = await GetSessionEventsAsync(tenantId, sessionId);
if (events.Count == 0) return null; // events aged out (retention) — no evidence, no row
var breakdown = TimeAttributionCalculator.Compute(new TimeAttributionInput
{
TenantId = tenantId,
SessionId = sessionId,
Status = session.Status.ToString(),
StartedAt = session.StartedAt,
CompletedAt = session.CompletedAt,
DurationSeconds = session.DurationSeconds,
IsPreProvisioned = session.IsPreProvisioned,
ResumedAt = session.ResumedAt,
Events = events,
});
if (breakdown == null) return null;
// Change signal for the sweep: a session whose EventCount moved after this
// write (late/replayed batches) gets recomputed from the fuller stream.
breakdown.EventCountAtCompute = session.EventCount;
return await StoreSessionTimeBreakdownAsync(breakdown) ? breakdown : null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Time-attribution compute failed for session {SessionId} (fail-soft)", sessionId);
return null;
}
}
/// <summary>Upserts the breakdown row (Replace — a recompute owns the whole row).</summary>
public async Task<bool> StoreSessionTimeBreakdownAsync(SessionTimeBreakdown breakdown)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionTimeBreakdowns);
await tableClient.UpsertEntityAsync(
BuildSessionTimeBreakdownEntity(breakdown), TableUpdateMode.Replace);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store time breakdown for session {SessionId}", breakdown.SessionId);
return false;
}
}
/// <summary>Point-reads one breakdown row; null when the session has none.</summary>
public async Task<SessionTimeBreakdown?> GetSessionTimeBreakdownAsync(string tenantId, string sessionId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionTimeBreakdowns);
var result = await tableClient.GetEntityIfExistsAsync<TableEntity>(tenantId, sessionId);
return result.HasValue ? MapToSessionTimeBreakdown(result.Value!) : null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get time breakdown for session {SessionId}", sessionId);
return null;
}
}
// internal static: entity builder + mapper are pinned by round-trip unit tests
// (table-storage-serialization rule — every property must survive Store→Map).
internal static TableEntity BuildSessionTimeBreakdownEntity(SessionTimeBreakdown b)
{
var entity = new TableEntity(b.TenantId, b.SessionId)
{
["AttributionVersion"] = b.AttributionVersion,
["EventCountAtCompute"] = b.EventCountAtCompute,
["WallClockSeconds"] = b.WallClockSeconds,
["UnattributedSeconds"] = b.UnattributedSeconds,
["RebootSeconds"] = b.RebootSeconds,
["BlockingAppCount"] = b.BlockingAppCount,
["QualityFlags"] = (int)b.QualityFlags,
["SegmentsJson"] = JsonSerializer.Serialize(b.Segments),
["RebootSpansJson"] = JsonSerializer.Serialize(b.RebootSpans),
["BlockingAppsJson"] = JsonSerializer.Serialize(b.BlockingApps),
["ComputedAt"] = DateTime.UtcNow,
};
// Tri-state: absent column = occupancy unknown (blocking set never observed) — the
// mapper must yield null, never 0 (truthfulness rule 1).
if (b.EspAppsOccupancySeconds.HasValue)
entity["EspAppsOccupancySeconds"] = b.EspAppsOccupancySeconds.Value;
return entity;
}
internal static SessionTimeBreakdown MapToSessionTimeBreakdown(TableEntity entity)
{
return new SessionTimeBreakdown
{
TenantId = entity.PartitionKey,
SessionId = entity.RowKey,
AttributionVersion = entity.GetInt32("AttributionVersion") ?? 0,
EventCountAtCompute = entity.GetInt32("EventCountAtCompute") ?? 0,
WallClockSeconds = entity.GetInt32("WallClockSeconds") ?? 0,
UnattributedSeconds = entity.GetInt32("UnattributedSeconds") ?? 0,
RebootSeconds = entity.GetInt32("RebootSeconds") ?? 0,
BlockingAppCount = entity.GetInt32("BlockingAppCount") ?? 0,
EspAppsOccupancySeconds = entity.GetInt32("EspAppsOccupancySeconds"),
QualityFlags = (TimeAttributionFlags)(entity.GetInt32("QualityFlags") ?? 0),
Segments = DeserializeJsonColumn<TimeAttributionSpan>(entity.GetString("SegmentsJson")),
RebootSpans = DeserializeJsonColumn<RebootSpan>(entity.GetString("RebootSpansJson")),
BlockingApps = DeserializeJsonColumn<BlockingAppInterval>(entity.GetString("BlockingAppsJson")),
};
}
private static List<T> DeserializeJsonColumn<T>(string? json)
{
if (string.IsNullOrEmpty(json)) return new List<T>();
try
{
return JsonSerializer.Deserialize<List<T>>(json!) ?? new List<T>();
}
catch (JsonException)
{
return new List<T>(); // corrupt/truncated column — degrade to empty, never throw on read
}
}
// ===== TIME ATTRIBUTION DAILY AGGREGATES =====
/// <summary>Upserts one daily aggregate row (Replace — the sweep recomputes rows whole).</summary>
public async Task<bool> SaveTimeAttributionAggregateAsync(TimeAttributionDailyAggregate aggregate)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TimeAttributionAggregates);
await tableClient.UpsertEntityAsync(
BuildTimeAttributionAggregateEntity(aggregate), TableUpdateMode.Replace);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save time-attribution aggregate {Date}/{Class} for tenant {TenantId}",
aggregate.Date, aggregate.EnrollmentClass, aggregate.TenantId);
return false;
}
}
// internal static: pinned by round-trip tests together with MapToTimeAttributionAggregate.
internal static TableEntity BuildTimeAttributionAggregateEntity(TimeAttributionDailyAggregate aggregate)
{
return new TableEntity(aggregate.TenantId, $"{aggregate.Date}|{aggregate.EnrollmentClass}")
{
["Date"] = aggregate.Date,
["EnrollmentClass"] = aggregate.EnrollmentClass,
["AttributionVersion"] = aggregate.AttributionVersion,
["CleanSessionCount"] = aggregate.CleanSessionCount,
["FlaggedExcludedCount"] = aggregate.FlaggedExcludedCount,
["MissingBreakdownCount"] = aggregate.MissingBreakdownCount,
["SegmentStatsJson"] = JsonSerializer.Serialize(aggregate.SegmentStats),
["TopBlockingAppsJson"] = JsonSerializer.Serialize(aggregate.TopBlockingApps),
["ComputedAt"] = EnsureUtc(aggregate.ComputedAt),
};
}
/// <summary>
/// Reads the aggregate rows of one tenant partition ("global" allowed) for an inclusive
/// date range. RowKeys are "{yyyy-MM-dd}|{class}", so a string range filter covers the
/// window; dates are server-formatted (injection-safe).
/// </summary>
public async Task<List<TimeAttributionDailyAggregate>> GetTimeAttributionAggregatesAsync(
string tenantId, DateTime startDate, DateTime endDate)
{
if (tenantId != "global")
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TimeAttributionAggregates);
// '~' (0x7E) sorts after '|' (0x7C): "{end}~" upper-bounds every "{end}|{class}" key.
var filter = $"PartitionKey eq '{tenantId}' and RowKey ge '{startDate:yyyy-MM-dd}' and RowKey lt '{endDate:yyyy-MM-dd}~'";
var results = new List<TimeAttributionDailyAggregate>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: filter))
{
results.Add(MapToTimeAttributionAggregate(entity));
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to query time-attribution aggregates for tenant {TenantId}", tenantId);
return new List<TimeAttributionDailyAggregate>();
}
}
/// <summary>
/// Reads the rolling-window aggregate rows (RK "rolling30|{class}") of one tenant
/// partition ("global" allowed) — the range statistics the fleet panel renders. The
/// date-range getter never returns these (its digit-prefixed upper bound sorts below
/// "rolling…"), and vice versa.
/// </summary>
public async Task<List<TimeAttributionDailyAggregate>> GetRollingTimeAttributionAggregatesAsync(string tenantId)
{
if (tenantId != "global")
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TimeAttributionAggregates);
// '}' (0x7D) sorts after '|' (0x7C) — prefix range over "rolling30|…".
var filter = $"PartitionKey eq '{tenantId}' and RowKey ge 'rolling30|' and RowKey lt 'rolling30}}'";
var results = new List<TimeAttributionDailyAggregate>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: filter))
{
results.Add(MapToTimeAttributionAggregate(entity));
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to query rolling time-attribution aggregates for tenant {TenantId}", tenantId);
return new List<TimeAttributionDailyAggregate>();
}
}
internal static TimeAttributionDailyAggregate MapToTimeAttributionAggregate(TableEntity entity)
{
return new TimeAttributionDailyAggregate
{
TenantId = entity.PartitionKey,
Date = entity.GetString("Date") ?? string.Empty,
EnrollmentClass = entity.GetString("EnrollmentClass") ?? string.Empty,
AttributionVersion = entity.GetInt32("AttributionVersion") ?? 0,
CleanSessionCount = entity.GetInt32("CleanSessionCount") ?? 0,
FlaggedExcludedCount = entity.GetInt32("FlaggedExcludedCount") ?? 0,
MissingBreakdownCount = entity.GetInt32("MissingBreakdownCount") ?? 0,
SegmentStats = DeserializeJsonColumn<TimeAttributionSegmentStat>(entity.GetString("SegmentStatsJson")),
TopBlockingApps = DeserializeJsonColumn<TimeAttributionBlockingAppStat>(entity.GetString("TopBlockingAppsJson")),
ComputedAt = entity.GetDateTimeOffset("ComputedAt")?.UtcDateTime ?? DateTime.MinValue,
};
}
/// <summary>
/// Deletes one aggregate row (daily "{date}|{class}" or rolling "rolling30|{class}").
/// Used by the sweep's stale-bucket reconcile: a bucket that was not regenerated this
/// run (its sessions were deleted or its class left the window) must not keep serving
/// old numbers. Missing rows are a no-op.
/// </summary>
public async Task DeleteTimeAttributionAggregateAsync(string tenantId, string dateKey, string enrollmentClass)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TimeAttributionAggregates);
await tableClient.DeleteEntityAsync(tenantId, $"{dateKey}|{enrollmentClass}");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete stale time-attribution aggregate {Date}/{Class} for tenant {TenantId}",
dateKey, enrollmentClass, tenantId);
}
}
/// <summary>
/// Retention: deletes aggregate rows older than the cutoff (RowKey starts with the date,
/// so a string compare works across partitions). Mirrors the UsageMetrics 180d policy.
/// </summary>
public async Task<int> DeleteTimeAttributionAggregatesOlderThanAsync(DateTime cutoffDate)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TimeAttributionAggregates);
var filter = $"RowKey lt '{cutoffDate:yyyy-MM-dd}'";
var deleted = 0;
await foreach (var entity in tableClient.QueryAsync<TableEntity>(
filter: filter, select: new[] { "PartitionKey", "RowKey" }))
{
await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
deleted++;
}
return deleted;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete old time-attribution aggregates");
return 0;
}
}
}
}