mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fix: session duration inflated by inherited tz-skewed IME log timestamps (#123)
Session df1fcf47 (WG Part 1, real ~1h16m) reported 8h15m: one do_telemetry
event inherited an unrelated, 8h-shifted IME log line timestamp via
LastMatchedLogTimestamp, and the backend re-anchored StartedAt on it.
Three layers:
- Agent: OnDoTelemetryReceived now carries the source-line timestamp; the
DO-collector poll path passes null and the adapter stamps the clock
instead of inheriting whatever line matched last.
- Backend: StartedAt alignment and duration-start selection reject
earliest-event timestamps more than 2h before the current anchor
(MaxStartedAtBackwardShift), on all four mutation sites incl. the
SessionsIndex dual-write decision.
- Parser: CMTrace time fields with a UTC-bias suffix ("+480") now parse
and honor the writer-declared offset instead of failing to match.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
efc96258d6
commit
9ba67162aa
+21
@@ -92,6 +92,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.InRange(entry.Timestamp, before.AddSeconds(-5), after.AddSeconds(5));
|
||||
}
|
||||
|
||||
// ── UTC-bias suffix on the time field → honored, not dropped ──────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("+480", 8 * 60)] // PST writer (UTC-8): UTC = local + 480min
|
||||
[InlineData("-060", -60)] // UTC+1 writer: UTC = local - 60min
|
||||
public void TryParseLine_applies_utc_bias_suffix(string bias, int expectedOffsetMinutes)
|
||||
{
|
||||
// Session df1fcf47: a writer whose timezone differs from the agent's stamped its
|
||||
// lines with an explicit bias. Without bias handling the line either failed to
|
||||
// match at all (old regex) or would be misread in agent-local time.
|
||||
var line = CmTraceLine("msg", "11:46:19.0226610" + bias, "7-29-2026");
|
||||
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(
|
||||
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
|
||||
entry.Timestamp);
|
||||
Assert.Equal(DateTimeKind.Utc, entry.Timestamp.Kind);
|
||||
}
|
||||
|
||||
// ── Oversized line → parses, no throw, no truncation of the message ───
|
||||
|
||||
[Fact]
|
||||
|
||||
+28
-5
@@ -83,13 +83,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
// Line-grounded path: the [DO TEL] line's own timestamp travels with the callback.
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddDays(-7));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddMinutes(-1));
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
|
||||
{
|
||||
// Session df1fcf47: the DO collector observed the completion live, but the event
|
||||
// inherited LastMatchedLogTimestamp from an unrelated, tz-skewed log line 8h in
|
||||
// the past — which dragged Sessions.StartedAt back and inflated the duration.
|
||||
// Poll-driven telemetry (null source timestamp) must stamp the clock, never the
|
||||
// last matched line.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do-poll", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddHours(-8);
|
||||
adapter.TriggerDoTelemetryFromTest(app, sourceTimestampUtc: null);
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow, info.OccurredAtUtc);
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
// Not a replay — live observation must never trip the replay suppression,
|
||||
// even with a stale unrelated line on the tracker.
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
-3
@@ -647,13 +647,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
int priorDoCalls = 0;
|
||||
int priorScriptCalls = 0;
|
||||
f.Tracker.OnImeAgentVersion = _ => priorVersionCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = _ => priorDoCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = (_, _) => priorDoCalls++;
|
||||
f.Tracker.OnScriptCompleted = _ => priorScriptCalls++;
|
||||
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.OnImeAgentVersion("1.0.0.0");
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0));
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0), null);
|
||||
f.Tracker.OnScriptCompleted(new ScriptExecutionState { PolicyId = "p", ScriptType = "platform" });
|
||||
|
||||
Assert.Equal(1, priorVersionCalls);
|
||||
@@ -667,7 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
Action<string> priorVersion = _ => { };
|
||||
Action<AppPackageState> priorDo = _ => { };
|
||||
Action<AppPackageState, DateTime?> priorDo = (_, _) => { };
|
||||
Action<ScriptExecutionState> priorScript = _ => { };
|
||||
f.Tracker.OnImeAgentVersion = priorVersion;
|
||||
f.Tracker.OnDoTelemetryReceived = priorDo;
|
||||
|
||||
+25
-5
@@ -22,9 +22,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// </summary>
|
||||
public static class CmTraceLogParser
|
||||
{
|
||||
// Pre-compiled regex for the CMTrace format
|
||||
// Pre-compiled regex for the CMTrace format. The time field may carry a UTC-bias
|
||||
// suffix in minutes ("06:08:04.8834397+480", GetTimeZoneInformation convention:
|
||||
// UTC = local + bias) — without the optional bias group such lines would not match
|
||||
// at all and their content would be invisible to every pattern.
|
||||
private static readonly Regex CmTraceRegex = new Regex(
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)(?<bias>[+-]\d{1,4})?""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline
|
||||
);
|
||||
|
||||
@@ -45,6 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
var message = match.Groups["message"].Value;
|
||||
var timeStr = match.Groups["time"].Value;
|
||||
var biasStr = match.Groups["bias"].Value;
|
||||
var dateStr = match.Groups["date"].Value;
|
||||
var component = match.Groups["component"].Value;
|
||||
var typeStr = match.Groups["type"].Value;
|
||||
@@ -52,7 +56,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// Parse timestamp: date is "M-d-yyyy", time is "HH:mm:ss.ticks"
|
||||
DateTime timestamp;
|
||||
if (!TryParseTimestamp(dateStr, timeStr, out timestamp))
|
||||
if (!TryParseTimestamp(dateStr, timeStr, biasStr, out timestamp))
|
||||
{
|
||||
timestamp = DateTime.UtcNow;
|
||||
}
|
||||
@@ -72,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseTimestamp(string dateStr, string timeStr, out DateTime result)
|
||||
private static bool TryParseTimestamp(string dateStr, string timeStr, string biasStr, out DateTime result)
|
||||
{
|
||||
result = DateTime.MinValue;
|
||||
|
||||
@@ -116,7 +120,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"M-d-yyyy HH:mm:ss"
|
||||
};
|
||||
|
||||
// CMTrace timestamps are always in LOCAL time. Parse as local, then convert to UTC.
|
||||
// Bias-suffixed time ("+480"): the writer declared its own UTC offset in minutes
|
||||
// (GetTimeZoneInformation convention: UTC = written time + bias). Honor it instead
|
||||
// of assuming the agent's local timezone — writer and agent can disagree (mixed
|
||||
// components, mid-enrollment timezone change; session df1fcf47).
|
||||
if (!string.IsNullOrEmpty(biasStr)
|
||||
&& int.TryParse(biasStr, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var biasMinutes))
|
||||
{
|
||||
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out result))
|
||||
{
|
||||
result = DateTime.SpecifyKind(result.AddMinutes(biasMinutes), DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// No bias: CMTrace timestamps are in LOCAL time. Parse as local, then convert to UTC.
|
||||
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeLocal, out result))
|
||||
{
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
|
||||
$"http={bytesFromHttp}, cache={bytesFromCacheServer}, mode={downloadMode}, duration={downloadDuration}");
|
||||
|
||||
OnDoTelemetryReceived?.Invoke(pkg);
|
||||
OnDoTelemetryReceived?.Invoke(pkg, LastMatchedLogTimestamp);
|
||||
_stateDirty = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -194,7 +194,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
|
||||
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
|
||||
// log line, null when the DO collector observed the completion live via polling. The
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
|
||||
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
getPackageStates: () => imeHost.PackageStates,
|
||||
onDoTelemetryReceived: pkg =>
|
||||
{
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
|
||||
// null source timestamp: poll-driven completion, no log line backs it —
|
||||
// the adapter stamps the clock instead of LastMatchedLogTimestamp.
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg, null); }
|
||||
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
|
||||
},
|
||||
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
|
||||
private readonly Action<string>? _prevOnPatternMatched;
|
||||
private readonly Action<string>? _prevOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
|
||||
private readonly Action<string> _ourOnPatternMatched;
|
||||
private readonly Action<string> _ourOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
|
||||
@@ -242,10 +242,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitImeAgentVersion(version);
|
||||
}
|
||||
|
||||
private void OnDoTelemetryReceived(AppPackageState app)
|
||||
private void OnDoTelemetryReceived(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
_prevOnDoTelemetryReceived?.Invoke(app);
|
||||
EmitDoTelemetry(app);
|
||||
_prevOnDoTelemetryReceived?.Invoke(app, sourceTimestampUtc);
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
}
|
||||
|
||||
private void OnScriptCompleted(ScriptExecutionState script)
|
||||
@@ -266,7 +266,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitAppState(app, oldState, newState);
|
||||
internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId);
|
||||
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
|
||||
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
|
||||
|
||||
@@ -876,7 +877,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
|
||||
}
|
||||
|
||||
private void EmitDoTelemetry(AppPackageState app)
|
||||
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
if (app == null || string.IsNullOrEmpty(app.Id)) return;
|
||||
|
||||
@@ -912,11 +913,31 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
// Two trigger paths with different time grounding (session df1fcf47: a single
|
||||
// tz-skewed unrelated log line inherited via LastMatchedLogTimestamp dragged the
|
||||
// session StartedAt back 7h and inflated the reported duration):
|
||||
// - [DO TEL] log line (sourceTimestampUtc set): the line's own timestamp is the
|
||||
// event time — resolve/clamp it and apply historic-replay suppression.
|
||||
// - DO collector poll (sourceTimestampUtc null): the completion was observed live
|
||||
// via Get-DeliveryOptimizationStatus; no log line is involved, so the clock is
|
||||
// the only honest source. LastMatchedLogTimestamp must NOT be consulted here.
|
||||
DateTime now;
|
||||
bool derivedFromClock;
|
||||
DateTime? rawSourceTs;
|
||||
if (sourceTimestampUtc.HasValue)
|
||||
{
|
||||
now = ResolveOccurredAt(sourceTimestampUtc, out derivedFromClock, out rawSourceTs);
|
||||
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
now = _clock.UtcNow;
|
||||
derivedFromClock = true;
|
||||
rawSourceTs = null;
|
||||
}
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Plausibility guard for the session anchor (session df1fcf47): ONE do_telemetry event whose
|
||||
/// OccurredUtc inherited a tz-shifted IME log line 8h in the past dragged StartedAt back ~7h
|
||||
/// and inflated the WG Part 1 DurationSeconds from ~1h16m to 8h15m. The earliest-event
|
||||
/// timestamp — whether from the ingest batch or the Events-table probe — may only re-anchor
|
||||
/// StartedAt / the duration start when it sits within
|
||||
/// <see cref="TableStorageService.MaxStartedAtBackwardShift"/> of the current anchor.
|
||||
/// </summary>
|
||||
public class SessionStartedAtBackwardShiftGuardTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static readonly DateTime StartedAt = new(2026, 7, 29, 18, 45, 15, DateTimeKind.Utc);
|
||||
|
||||
// ==================================================== StartedAt alignment ====
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementEventCount_rejects_backward_shift_beyond_window()
|
||||
{
|
||||
var harness = new Harness(SessionRow());
|
||||
|
||||
// The df1fcf47 shape: one event 8h before the session's real start.
|
||||
var skewed = StartedAt.AddHours(-7);
|
||||
var result = await harness.Sut.IncrementSessionEventCountAsync(
|
||||
TenantId, SessionId, increment: 1,
|
||||
earliestEventTimestamp: skewed, latestEventTimestamp: StartedAt.AddMinutes(10));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.False(harness.Written!.ContainsKey("StartedAt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementEventCount_accepts_backward_shift_within_window()
|
||||
{
|
||||
var harness = new Harness(SessionRow());
|
||||
|
||||
// Legitimate pre-registration backlog: minutes, not hours.
|
||||
var earlier = StartedAt.AddMinutes(-30);
|
||||
var result = await harness.Sut.IncrementSessionEventCountAsync(
|
||||
TenantId, SessionId, increment: 1,
|
||||
earliestEventTimestamp: earlier, latestEventTimestamp: StartedAt.AddMinutes(10));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal(new DateTimeOffset(earlier), harness.Written!.GetDateTimeOffset("StartedAt"));
|
||||
}
|
||||
|
||||
// ============================================= Pending (WG Part 1) duration ====
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_ignores_skewed_stored_earliest_event()
|
||||
{
|
||||
// Exact df1fcf47 replica: the poisoned event is already IN the Events table
|
||||
// (probe returns it) AND rides in the batch as earliestEventTimestamp.
|
||||
var poisoned = StartedAt.AddHours(-7);
|
||||
var lastEvent = new DateTime(2026, 7, 29, 20, 1, 9, DateTimeKind.Utc);
|
||||
var harness = new Harness(SessionRow(), earliestStoredEvent: poisoned);
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
earliestEventTimestamp: poisoned, latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
// Duration anchors on StartedAt (18:45:15 → 20:01:09 = 4554s), not on the skewed
|
||||
// event (which would give 29690s less the 7h offset — the original inflation).
|
||||
Assert.Equal((int)(lastEvent - StartedAt).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
Assert.False(harness.Written.ContainsKey("StartedAt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_uses_plausible_stored_earliest_event()
|
||||
{
|
||||
var earliest = StartedAt.AddMinutes(-5);
|
||||
var lastEvent = StartedAt.AddMinutes(70);
|
||||
var harness = new Harness(SessionRow(), earliestStoredEvent: earliest);
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal((int)(lastEvent - earliest).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
private static TableEntity SessionRow()
|
||||
{
|
||||
var entity = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(StartedAt),
|
||||
["Status"] = "InProgress",
|
||||
["EventCount"] = 10,
|
||||
};
|
||||
entity.ETag = new ETag("0xEXISTING");
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK-mock harness (same shape as <see cref="StoreSessionReregistrationPreserveTests"/>):
|
||||
/// Sessions table configured for read + merge-capture; Events table optionally serves the
|
||||
/// earliest-event probe; SessionsIndex stays unconfigured — its sync is swallow-all in the
|
||||
/// SUT and irrelevant here.
|
||||
/// </summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableStorageService Sut { get; }
|
||||
public TableEntity? Written { get; private set; }
|
||||
|
||||
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null)
|
||||
{
|
||||
var sessions = new Mock<TableClient>();
|
||||
|
||||
sessions.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(existing, new Mock<Response>().Object));
|
||||
|
||||
var ifExists = new Mock<NullableResponse<TableEntity>>();
|
||||
ifExists.SetupGet(r => r.HasValue).Returns(true);
|
||||
ifExists.SetupGet(r => r.Value).Returns(existing);
|
||||
sessions.Setup(t => t.GetEntityIfExistsAsync<TableEntity>(
|
||||
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ifExists.Object);
|
||||
|
||||
sessions.Setup(t => t.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
|
||||
{
|
||||
Written = e;
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
var events = new Mock<TableClient>();
|
||||
var eventRows = earliestStoredEvent.HasValue
|
||||
? new[]
|
||||
{
|
||||
new TableEntity($"{TenantId}_{SessionId}", $"{earliestStoredEvent.Value:yyyyMMddHHmmssfff}_0000000001")
|
||||
{
|
||||
["OccurredUtc"] = new DateTimeOffset(earliestStoredEvent.Value),
|
||||
},
|
||||
}
|
||||
: Array.Empty<TableEntity>();
|
||||
events.Setup(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[]
|
||||
{
|
||||
Page<TableEntity>.FromValues(eventRows, null, new Mock<Response>().Object),
|
||||
}));
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(sessions.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Events)).Returns(events.Object);
|
||||
// SessionsIndex intentionally unconfigured → null client → SUT swallows.
|
||||
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,45 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound for how far the earliest-event timestamp may sit BEFORE the session's
|
||||
/// current StartedAt and still be trusted as the new session anchor. Legitimate cases
|
||||
/// (events ingested before registration, agent bootstrapping mid-ESP and replaying
|
||||
/// accumulated IME log lines) are minutes to at most ~an hour. Anything beyond is a
|
||||
/// skewed source timestamp — session df1fcf47: ONE do_telemetry event whose OccurredUtc
|
||||
/// inherited a tz-shifted IME log line 8h in the past re-anchored StartedAt and inflated
|
||||
/// DurationSeconds from ~1h16m to 8h15m. The agent accepts source timestamps up to 24h
|
||||
/// old by design (replay timeline fidelity), so the session anchor needs its own,
|
||||
/// much tighter plausibility window.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan MaxStartedAtBackwardShift = TimeSpan.FromHours(2);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <paramref name="earliestEventTimestamp"/> when it is a plausible session
|
||||
/// anchor (missing anchor, forward-in-time, or backward within
|
||||
/// <see cref="MaxStartedAtBackwardShift"/>), null when the backward shift is implausible.
|
||||
/// Mutation sites MUST apply this before BOTH the StartedAt alignment and the
|
||||
/// SyncSessionIndexAsync call — the index derives its shift-vs-merge decision from the
|
||||
/// same comparison, so feeding it the unsanitized value would move the index RowKey
|
||||
/// while the session row keeps its anchor. The rejected event itself stays in the
|
||||
/// timeline; only the anchor refuses to follow it.
|
||||
/// </summary>
|
||||
private DateTime? SanitizeEarliestEventTimestamp(
|
||||
DateTime? earliestEventTimestamp, DateTime currentStartedAt, string sessionId)
|
||||
{
|
||||
if (!earliestEventTimestamp.HasValue || currentStartedAt == DateTime.MaxValue)
|
||||
return earliestEventTimestamp;
|
||||
|
||||
var backwardShift = currentStartedAt - earliestEventTimestamp.Value;
|
||||
if (backwardShift <= MaxStartedAtBackwardShift)
|
||||
return earliestEventTimestamp;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Session {SessionId}: earliest event timestamp {EarliestEventTimestamp:o} is {ShiftHours:F1}h before StartedAt {StartedAt:o} — backward shift rejected as skewed source timestamp",
|
||||
sessionId, earliestEventTimestamp.Value, backwardShift.TotalHours, currentStartedAt);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Centralizes the SessionsIndex dual-write decision shared by the session-mutation paths
|
||||
/// (UpdateSessionStatusAsync normal + ETag force-write, IncrementSessionEventCountAsync): when
|
||||
@@ -483,8 +522,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
// If events were ingested before session registration succeeded, align StartedAt
|
||||
// with the earliest event we already have for this session.
|
||||
var earliestEventTimestamp = await GetEarliestSessionEventTimestampAsync(registration.TenantId, registration.SessionId);
|
||||
// with the earliest event we already have for this session (plausibility-guarded:
|
||||
// a skewed source timestamp must not pre-poison the anchor at registration).
|
||||
var earliestEventTimestamp = SanitizeEarliestEventTimestamp(
|
||||
await GetEarliestSessionEventTimestampAsync(registration.TenantId, registration.SessionId),
|
||||
startedAt, registration.SessionId);
|
||||
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < startedAt)
|
||||
{
|
||||
startedAt = earliestEventTimestamp.Value;
|
||||
@@ -1655,10 +1697,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
// Align StartedAt with the earliest event timestamp provided by the caller
|
||||
// (plausibility-guarded — one skewed event must not re-anchor the session).
|
||||
var currentStartedAt = session.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue;
|
||||
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < currentStartedAt)
|
||||
var safeEarliestEventTimestamp = SanitizeEarliestEventTimestamp(earliestEventTimestamp, currentStartedAt, sessionId);
|
||||
if (safeEarliestEventTimestamp.HasValue && safeEarliestEventTimestamp.Value < currentStartedAt)
|
||||
{
|
||||
update["StartedAt"] = EnsureUtc(earliestEventTimestamp.Value);
|
||||
update["StartedAt"] = EnsureUtc(safeEarliestEventTimestamp.Value);
|
||||
}
|
||||
|
||||
// Set completion time if succeeded or failed
|
||||
@@ -1688,11 +1732,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Standard session (or WhiteGlove without stored Part 1 data — fallback):
|
||||
// Read earliest event from Events table — authoritative source, immune to
|
||||
// concurrent StartedAt update races. This is a single-row lookup (maxPerPage: 1)
|
||||
// and only happens once per session lifecycle (at completion).
|
||||
var earliestStoredEvent = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId);
|
||||
// and only happens once per session lifecycle (at completion). Same
|
||||
// plausibility guard as the anchor: one skewed stored event must not
|
||||
// stretch the duration by hours.
|
||||
var earliestStoredEvent = SanitizeEarliestEventTimestamp(
|
||||
await GetEarliestSessionEventTimestampAsync(tenantId, sessionId), currentStartedAt, sessionId);
|
||||
var durationStart = earliestStoredEvent ?? currentStartedAt;
|
||||
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < durationStart)
|
||||
durationStart = earliestEventTimestamp.Value;
|
||||
if (safeEarliestEventTimestamp.HasValue && safeEarliestEventTimestamp.Value < durationStart)
|
||||
durationStart = safeEarliestEventTimestamp.Value;
|
||||
|
||||
if (durationStart < effectiveCompletedAt)
|
||||
update["DurationSeconds"] = (int)(effectiveCompletedAt - durationStart).TotalSeconds;
|
||||
@@ -1712,10 +1759,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
if (latestEventTimestamp.HasValue)
|
||||
{
|
||||
var earliestStoredEvent = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId);
|
||||
// Same plausibility guard as the completion path (session df1fcf47:
|
||||
// this exact block computed the inflated 8h15m WG Part 1 duration).
|
||||
var earliestStoredEvent = SanitizeEarliestEventTimestamp(
|
||||
await GetEarliestSessionEventTimestampAsync(tenantId, sessionId), currentStartedAt, sessionId);
|
||||
var durationStart = earliestStoredEvent ?? currentStartedAt;
|
||||
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < durationStart)
|
||||
durationStart = earliestEventTimestamp.Value;
|
||||
if (safeEarliestEventTimestamp.HasValue && safeEarliestEventTimestamp.Value < durationStart)
|
||||
durationStart = safeEarliestEventTimestamp.Value;
|
||||
|
||||
if (durationStart < latestEventTimestamp.Value)
|
||||
update["DurationSeconds"] = (int)(latestEventTimestamp.Value - durationStart).TotalSeconds;
|
||||
@@ -1826,7 +1876,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
await tableClient.UpdateEntityAsync(update, session.ETag, TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: keep SessionsIndex in sync (StartedAt-shift → full upsert, else merge).
|
||||
await SyncSessionIndexAsync(tenantId, sessionId, session, update, currentStartedAt, earliestEventTimestamp);
|
||||
await SyncSessionIndexAsync(tenantId, sessionId, session, update, currentStartedAt, safeEarliestEventTimestamp);
|
||||
|
||||
_logger.LogInformation($"Updated session {sessionId} status to {status}");
|
||||
return true;
|
||||
@@ -1919,8 +1969,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
var freshStartedAt = freshSession.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue;
|
||||
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < freshStartedAt)
|
||||
forceUpdate["StartedAt"] = EnsureUtc(earliestEventTimestamp.Value);
|
||||
// Re-sanitize against the FRESH anchor (mirror of the normal path's guard).
|
||||
var safeForceEarliestEventTimestamp = SanitizeEarliestEventTimestamp(earliestEventTimestamp, freshStartedAt, sessionId);
|
||||
if (safeForceEarliestEventTimestamp.HasValue && safeForceEarliestEventTimestamp.Value < freshStartedAt)
|
||||
forceUpdate["StartedAt"] = EnsureUtc(safeForceEarliestEventTimestamp.Value);
|
||||
|
||||
if (status == SessionStatus.Succeeded || status == SessionStatus.Failed)
|
||||
{
|
||||
@@ -1943,10 +1995,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
else
|
||||
{
|
||||
var earliestStoredEvent = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId);
|
||||
var earliestStoredEvent = SanitizeEarliestEventTimestamp(
|
||||
await GetEarliestSessionEventTimestampAsync(tenantId, sessionId), freshStartedAt, sessionId);
|
||||
var durationStart = earliestStoredEvent ?? freshStartedAt;
|
||||
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < durationStart)
|
||||
durationStart = earliestEventTimestamp.Value;
|
||||
if (safeForceEarliestEventTimestamp.HasValue && safeForceEarliestEventTimestamp.Value < durationStart)
|
||||
durationStart = safeForceEarliestEventTimestamp.Value;
|
||||
|
||||
if (durationStart < effectiveCompletedAt)
|
||||
forceUpdate["DurationSeconds"] = (int)(effectiveCompletedAt - durationStart).TotalSeconds;
|
||||
@@ -1956,10 +2009,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
if (latestEventTimestamp.HasValue)
|
||||
{
|
||||
var earliestStoredEvent = await GetEarliestSessionEventTimestampAsync(tenantId, sessionId);
|
||||
var earliestStoredEvent = SanitizeEarliestEventTimestamp(
|
||||
await GetEarliestSessionEventTimestampAsync(tenantId, sessionId), freshStartedAt, sessionId);
|
||||
var durationStart = earliestStoredEvent ?? freshStartedAt;
|
||||
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < durationStart)
|
||||
durationStart = earliestEventTimestamp.Value;
|
||||
if (safeForceEarliestEventTimestamp.HasValue && safeForceEarliestEventTimestamp.Value < durationStart)
|
||||
durationStart = safeForceEarliestEventTimestamp.Value;
|
||||
|
||||
if (durationStart < latestEventTimestamp.Value)
|
||||
forceUpdate["DurationSeconds"] = (int)(latestEventTimestamp.Value - durationStart).TotalSeconds;
|
||||
@@ -2026,7 +2080,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
await forceTableClient.UpdateEntityAsync(forceUpdate, ETag.All, TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: keep SessionsIndex in sync (StartedAt-shift → full upsert, else merge).
|
||||
await SyncSessionIndexAsync(tenantId, sessionId, freshSession, forceUpdate, freshStartedAt, earliestEventTimestamp);
|
||||
await SyncSessionIndexAsync(tenantId, sessionId, freshSession, forceUpdate, freshStartedAt, safeForceEarliestEventTimestamp);
|
||||
|
||||
_logger.LogInformation($"Force-updated session {sessionId} status to {status} (unconditional merge after ETag exhaustion)");
|
||||
return true;
|
||||
@@ -2119,10 +2173,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
// Align StartedAt with the earliest event timestamp provided by the caller
|
||||
// (plausibility-guarded — one skewed event must not re-anchor the session).
|
||||
var currentStartedAt = entity.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.MaxValue;
|
||||
if (earliestEventTimestamp.HasValue && earliestEventTimestamp.Value < currentStartedAt)
|
||||
var safeEarliestEventTimestamp = SanitizeEarliestEventTimestamp(earliestEventTimestamp, currentStartedAt, sessionId);
|
||||
if (safeEarliestEventTimestamp.HasValue && safeEarliestEventTimestamp.Value < currentStartedAt)
|
||||
{
|
||||
update["StartedAt"] = EnsureUtc(earliestEventTimestamp.Value);
|
||||
update["StartedAt"] = EnsureUtc(safeEarliestEventTimestamp.Value);
|
||||
}
|
||||
|
||||
// Track the most recent event timestamp for excessive data sender detection
|
||||
@@ -2159,7 +2215,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
await tableClient.UpdateEntityAsync(update, entity.ETag, TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: keep SessionsIndex in sync (StartedAt-shift → full upsert, else merge).
|
||||
await SyncSessionIndexAsync(tenantId, sessionId, entity, update, currentStartedAt, earliestEventTimestamp);
|
||||
await SyncSessionIndexAsync(tenantId, sessionId, entity, update, currentStartedAt, safeEarliestEventTimestamp);
|
||||
|
||||
// Apply the merged fields onto the RMW read and map it through the shared
|
||||
// mapper — the post-merge snapshot the caller would otherwise re-read.
|
||||
|
||||
Reference in New Issue
Block a user