feat: add optional receivedAt field to EnrollmentEvent interface

This commit is contained in:
Oliver Kieselbach
2026-03-19 10:50:35 +01:00
parent 3046d4fb00
commit 90309f9c3e
10 changed files with 74 additions and 34 deletions
@@ -684,16 +684,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// distinguishable from the later full-enrollment package.
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
// Step 4: Persist final sequence for Boot 2 continuation
_sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence));
_logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot");
// Step 5: Mark WhiteGlove Part 1 as complete so the next boot (Part 2)
// Step 4: Mark WhiteGlove Part 1 as complete so the next boot (Part 2)
// can signal the backend to transition Pending → InProgress.
_sessionPersistence.SaveWhiteGloveComplete();
_logger.Info("WhiteGlove marker persisted for Part 2 detection");
// Step 6: Exit — session.id + session.seq + whiteglove.complete survive so
// Step 5: Exit — session.id + session.seq + whiteglove.complete survive so
// the agent resumes on next boot.
// No DeleteSessionId, no WriteEnrollmentCompleteMarker, no self-destruct.
// The Windows Autopilot shutdown will power off the device;
@@ -712,6 +708,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
});
await UploadEventsAsync();
// Step 6: Persist final sequence AFTER all events have been emitted and uploaded.
// Must be the last mutation before exit so Part 2's LoadSequence() returns
// the true final value — prevents duplicate sequence numbers across reboots.
_sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence));
_logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot");
_logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
Environment.Exit(0);
}
@@ -1 +1 @@
491
493
@@ -121,6 +121,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]";
_logger.LogInformation($"{sessionPrefix} IngestEvents: Processing {request.Events.Count} events (Device: {validation.CertificateThumbprint}, Hardware: {validation.Manufacturer} {validation.Model}, Rate: {validation.RateLimitResult?.RequestsInWindow}/{validation.RateLimitResult?.MaxRequests})");
// Stamp server-side receive time on all events in this batch
var receivedAt = DateTime.UtcNow;
foreach (var evt in request.Events)
evt.ReceivedAt = receivedAt;
// Store events in Azure Table Storage (batch write for efficiency)
var storedEvents = await _storageService.StoreEventsBatchAsync(request.Events);
int processedCount = storedEvents.Count;
@@ -448,7 +448,8 @@ namespace AutopilotMonitor.Functions.Services
evt.Data != null && evt.Data.Count > 0
? JsonConvert.SerializeObject(evt.Data)
: string.Empty,
"DataJson", evt.EventId)
"DataJson", evt.EventId),
["ReceivedAt"] = evt.ReceivedAt
};
await tableClient.UpsertEntityAsync(entity);
@@ -520,7 +521,8 @@ namespace AutopilotMonitor.Functions.Services
evt.Data != null && evt.Data.Count > 0
? JsonConvert.SerializeObject(evt.Data)
: string.Empty,
"DataJson", evt.EventId)
"DataJson", evt.EventId),
["ReceivedAt"] = evt.ReceivedAt
};
return new TableTransactionAction(TableTransactionActionType.UpsertReplace, entity);
@@ -1176,8 +1178,9 @@ namespace AutopilotMonitor.Functions.Services
events.Add(MapToEnrollmentEvent(entity));
}
// Sort by Timestamp ascending (chronological order)
return events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
// Sort by Sequence ascending — the authoritative event order
// (assigned atomically via Interlocked.Increment on the agent)
return events.OrderBy(e => e.Sequence).ToList();
}
catch (Exception ex)
{
@@ -1379,7 +1382,8 @@ namespace AutopilotMonitor.Functions.Services
Message = entity.GetString("Message") ?? string.Empty,
Sequence = entity.GetInt64("Sequence") ?? 0,
Data = DeserializeEventData(entity.GetString("DataJson")),
RowKey = entity.RowKey
RowKey = entity.RowKey,
ReceivedAt = entity.GetDateTimeOffset("ReceivedAt")?.UtcDateTime
};
}
@@ -32,6 +32,12 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// Server-side UTC timestamp set when the backend receives and stores this event.
/// Null for events that pre-date this feature. Never set by the agent.
/// </summary>
public DateTime? ReceivedAt { get; set; }
/// <summary>
/// Type of event (e.g., "phase_transition", "app_install_start", "error")
/// </summary>
@@ -23,7 +23,6 @@ interface EventTimelineProps {
preProvGrouped: { eventsByPhase: Record<string, EnrollmentEvent[]>; orderedPhases: string[] };
userEnrollGrouped: { eventsByPhase: Record<string, EnrollmentEvent[]>; orderedPhases: string[] };
userEnrollEvents: EnrollmentEvent[];
isGalacticAdmin?: boolean;
preProvDuration?: string | null;
userEnrollDuration?: string | null;
showScriptOutput?: boolean;
@@ -48,7 +47,6 @@ export default function EventTimeline({
preProvGrouped,
userEnrollGrouped,
userEnrollEvents,
isGalacticAdmin,
preProvDuration,
userEnrollDuration,
showScriptOutput,
@@ -188,7 +186,6 @@ export default function EventTimeline({
events={filterPhaseEvents(preProvGrouped.eventsByPhase[phaseName])}
isExpanded={expandedPhases.has(`pre-${phaseName}`)}
onToggle={() => togglePhase(`pre-${phaseName}`)}
isGalacticAdmin={isGalacticAdmin}
showScriptOutput={showScriptOutput}
/>
))}
@@ -226,7 +223,6 @@ export default function EventTimeline({
events={filterPhaseEvents(userEnrollGrouped.eventsByPhase[phaseName])}
isExpanded={expandedPhases.has(`user-${phaseName}`)}
onToggle={() => togglePhase(`user-${phaseName}`)}
isGalacticAdmin={isGalacticAdmin}
showScriptOutput={showScriptOutput}
/>
))}
@@ -267,7 +263,6 @@ export default function EventTimeline({
events={filterPhaseEvents(eventsByPhase[phaseName])}
isExpanded={expandedPhases.has(phaseName)}
onToggle={() => togglePhase(phaseName)}
isGalacticAdmin={isGalacticAdmin}
showScriptOutput={showScriptOutput}
/>
))}
@@ -286,14 +281,12 @@ function PhaseSection({
events,
isExpanded,
onToggle,
isGalacticAdmin,
showScriptOutput
}: {
phaseName: string;
events: EnrollmentEvent[];
isExpanded: boolean;
onToggle: () => void;
isGalacticAdmin?: boolean;
showScriptOutput?: boolean;
}) {
return (
@@ -316,7 +309,6 @@ function PhaseSection({
<EventRow
key={event.eventId || `${event.sessionId}-${event.sequence}`}
event={event}
isGalacticAdmin={isGalacticAdmin}
showScriptOutput={showScriptOutput}
/>
))}
@@ -326,7 +318,7 @@ function PhaseSection({
);
}
function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: EnrollmentEvent; isGalacticAdmin?: boolean; showScriptOutput?: boolean }) {
function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScriptOutput?: boolean }) {
const [showDetails, setShowDetails] = useState(false);
const [showRaw, setShowRaw] = useState(false);
const [copied, setCopied] = useState(false);
@@ -386,7 +378,8 @@ function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: Enrollm
}
};
const hasDetails = isTruncated || (detailData && Object.keys(detailData).length > 0);
const hasData = isTruncated || (detailData && Object.keys(detailData).length > 0);
const hasDetails = true; // Every event has at least the metadata block
return (
<div id={`event-${event.eventId}`} className="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors">
@@ -403,15 +396,6 @@ function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: Enrollm
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
<span>Source: {event.source}</span>
<span>Seq: {event.sequence}</span>
{isGalacticAdmin && (
<button
onClick={copyEventId}
className="font-mono hover:text-blue-600 cursor-pointer transition-colors"
title={copied ? 'Copied!' : `Click to copy full EventId: ${event.eventId}`}
>
EventId: {event.eventId.substring(0, 8)}... {copied && '✓'}
</button>
)}
</div>
</div>
{hasDetails && (
@@ -424,6 +408,43 @@ function EventRow({ event, isGalacticAdmin, showScriptOutput }: { event: Enrollm
)}
</div>
{/* Event metadata block — always shown when details are expanded */}
{showDetails && (() => {
const receivedDelta = event.receivedAt
? Math.round((new Date(event.receivedAt).getTime() - new Date(event.timestamp).getTime()) / 1000 * 10) / 10
: null;
return (
<div className="mt-2 border border-gray-200 rounded-md px-3 py-2 text-xs text-gray-600">
<div className="flex">
<span className="w-20 flex-shrink-0 text-gray-400">EventId</span>
<span className="flex-1 min-w-0 font-mono truncate">{event.eventId}</span>
<button
onClick={copyEventId}
className="ml-2 flex-shrink-0 text-gray-400 hover:text-blue-600 transition-colors"
title={copied ? 'Copied!' : 'Copy EventId'}
>
{copied ? '✓' : 'copy'}
</button>
<span className="w-20 flex-shrink-0 text-gray-400 ml-4">Phase</span>
<span className="flex-shrink-0">{event.phaseName || 'Unknown'}</span>
</div>
<div className="flex mt-0.5">
<span className="w-20 flex-shrink-0 text-gray-400">Created</span>
<span className="flex-1 min-w-0 font-mono">{event.timestamp}</span>
<span className="w-20 flex-shrink-0 text-gray-400 ml-6">Received</span>
<span className="flex-shrink-0 font-mono">
{event.receivedAt
? new Date(event.receivedAt).toISOString().replace('T', ' ').replace('Z', '')
: '—'}
{receivedDelta !== null && receivedDelta >= 0 && (
<span className="text-gray-400 ml-1">(+{receivedDelta}s)</span>
)}
</span>
</div>
</div>
);
})()}
{/* Gather rule: terminal-style output block */}
{showDetails && hasGatherOutput && (
<div className="mt-3">
@@ -1007,7 +1007,6 @@ export default function SessionDetailPage() {
preProvGrouped={preProvGrouped}
userEnrollGrouped={userEnrollGrouped}
userEnrollEvents={userEnrollEvents}
isGalacticAdmin={user?.isGalacticAdmin}
preProvDuration={whiteGloveDurations.preProvDuration}
userEnrollDuration={whiteGloveDurations.userEnrollDuration}
showScriptOutput={showScriptOutput}
@@ -16,6 +16,7 @@ export interface SessionExportEvent {
message: string;
sequence: number;
rowKey?: string;
receivedAt?: string;
data?: Record<string, unknown>;
}
@@ -51,7 +52,7 @@ export function generateCsvExport(events: SessionExportEvent[]) {
// Columns match the Events Azure Table Storage schema exactly
// PartitionKey = TenantId_SessionId, RowKey = Timestamp_Sequence
// PhaseName is a derived extra column appended after DataJson for convenience
const header = "PartitionKey,RowKey,EventId,SessionId,TenantId,Timestamp,EventType,Severity,Source,Phase,Message,Sequence,DataJson,PhaseName";
const header = "PartitionKey,RowKey,EventId,SessionId,TenantId,Timestamp,ReceivedAt,EventType,Severity,Source,Phase,Message,Sequence,DataJson,PhaseName";
const rows = sorted.map(e => {
const sev = e.severity ?? "";
const sevInt = SEVERITY_INT[sev] ?? SEVERITY_INT[sev.charAt(0).toUpperCase() + sev.slice(1).toLowerCase()];
@@ -63,6 +64,7 @@ export function generateCsvExport(events: SessionExportEvent[]) {
esc(e.sessionId ?? ""),
esc(e.tenantId ?? ""),
esc(e.timestamp ?? ""),
esc(e.receivedAt ?? ""),
esc(e.eventType ?? ""),
esc(severityCell),
esc(e.source ?? ""),
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ export interface EnrollmentEvent {
phaseName?: string;
message: string;
sequence: number;
receivedAt?: string;
data?: Record<string, any>;
}