mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(agent): event-driven Office (M365 Apps C2R) install detector + DO telemetry
Surfaces the real Microsoft 365 Apps Click-to-Run install lifecycle that the Intune "integrated" app hides (IME reports done in 1-2 min while C2R keeps streaming in the background). V2-only. Event-driven, no idle polling: - OfficeProcessWatcher: WMI Win32_ProcessStartTrace push on OfficeC2RClient.exe (+ startup probe) for start; Process.Exited for stop, debounced by a settle window (OfficeInstallSettleSeconds, default 10s) so short worker gaps in one install don't trigger a premature office_install_failed. - RegistryChangeWatcher: RegNotifyChangeKeyValue push on ...\Office\ClickToRun (Configuration value + Scenario subkey churn); bootstrap fallback watches the nearest existing ancestor non-recursive + name-only until ClickToRun appears. - OfficeInstallDetector: pure state machine (Idle->Active->Terminal), emits office_install_started/progress/completed/failed (progress on real change only, no heartbeat). Error code only from non-zero numeric/hex Scenario values. Office Delivery Optimization: - DeliveryOptimizationCollector extended with a 2nd wake source (Office active), classifies non-IME Office-CDN jobs and folds aggregated DO bytes/peers/real download-% into the office_install_* events (not emitted as download_progress to avoid a phantom app in the backend AppInstallSummary). Plumbing: office_install_* event-type constants, EnableOfficeInstallDetector + OfficeInstallSettleSeconds config (ConfigVersion 28->29), MCP + web event-type catalogs, ANALYZE-OFFICE-001 (office_install_failed -> Warning). 22 detector unit tests; full V2 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-OFFICE-001",
|
||||
"title": "Microsoft 365 Apps Install Failed",
|
||||
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "office_install_failed",
|
||||
"source": "event_type",
|
||||
"eventType": "office_install_failed",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "error_code_present", "condition": "exists", "weight": 15 }
|
||||
],
|
||||
"confidenceThreshold": 40,
|
||||
"explanation": "The Microsoft 365 Apps (Office Click-to-Run) install did not finish.\n\nThe OfficeInstallDetector watches the C2R client directly and reports the real background install lifecycle. The Intune \"integrated\" Microsoft 365 Apps app type only pushes the configuration XML and starts the C2R bootstrapper, so IME marks the app done within 1-2 minutes while C2R keeps streaming and laying down the product for several more minutes. This event means C2R activity ended (the OfficeC2RClient worker exited and the install scenario cleared) without the StreamingFinished flag being set, or a C2R failure code was observed.\n\nReview the office_install_failed event payload: `products`, `channel`, `phase`, `streamingFinished`, `errorCode` (best-effort — may be empty) and `durationSeconds`.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the Office C2R install failure",
|
||||
"steps": [
|
||||
"Open the office_install_failed event and review products, channel, phase, streamingFinished and errorCode",
|
||||
"Check the Click-to-Run logs on the device (%windir%\\Temp and %temp%, files named <MachineName>-yyyymmdd-nnnn.log) for the failing operation",
|
||||
"Verify the device had network reachability to the Office CDN / Microsoft Connected Cache during the enrollment window",
|
||||
"Confirm the Intune Microsoft 365 Apps app configuration (products, channel, architecture) matches what the device can install",
|
||||
"Re-check the session timeline for a reboot or agent shutdown that may have interrupted the background C2R install"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "office", "microsoft-365-apps", "click-to-run", "install-failure"]
|
||||
}
|
||||
+48
@@ -1351,6 +1351,54 @@
|
||||
"security"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-OFFICE-001",
|
||||
"title": "Microsoft 365 Apps Install Failed",
|
||||
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "office_install_failed",
|
||||
"source": "event_type",
|
||||
"eventType": "office_install_failed",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "error_code_present",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 40,
|
||||
"explanation": "The Microsoft 365 Apps (Office Click-to-Run) install did not finish.\n\nThe OfficeInstallDetector watches the C2R client directly and reports the real background install lifecycle. The Intune \"integrated\" Microsoft 365 Apps app type only pushes the configuration XML and starts the C2R bootstrapper, so IME marks the app done within 1-2 minutes while C2R keeps streaming and laying down the product for several more minutes. This event means C2R activity ended (the OfficeC2RClient worker exited and the install scenario cleared) without the StreamingFinished flag being set, or a C2R failure code was observed.\n\nReview the office_install_failed event payload: `products`, `channel`, `phase`, `streamingFinished`, `errorCode` (best-effort — may be empty) and `durationSeconds`.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the Office C2R install failure",
|
||||
"steps": [
|
||||
"Open the office_install_failed event and review products, channel, phase, streamingFinished and errorCode",
|
||||
"Check the Click-to-Run logs on the device (%windir%\\Temp and %temp%, files named <MachineName>-yyyymmdd-nnnn.log) for the failing operation",
|
||||
"Verify the device had network reachability to the Office CDN / Microsoft Connected Cache during the enrollment window",
|
||||
"Confirm the Intune Microsoft 365 Apps app configuration (products, channel, architecture) matches what the device can install",
|
||||
"Re-check the session timeline for a reboot or agent shutdown that may have interrupted the background C2R install"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"apps",
|
||||
"office",
|
||||
"microsoft-365-apps",
|
||||
"click-to-run",
|
||||
"install-failure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-001",
|
||||
"title": "Secure Boot UEFI CA 2023 certificate not deployed",
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// OfficeInstallDetector (Rev 2, event-driven) — the pure decision core driven via its On*
|
||||
/// entry points with synthetic snapshots (no real WMI / RegNotify / registry). Asserts: events
|
||||
/// only after a worker start, a single started, progress on real change only (no heartbeat),
|
||||
/// DO-sample folding (download-%), and completed / failed terminal resolution with version + duration.
|
||||
/// </summary>
|
||||
public sealed class OfficeInstallDetectorTests
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 6, 8, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info);
|
||||
|
||||
private sealed class Rig : IDisposable
|
||||
{
|
||||
public FakeSignalIngressSink Sink { get; }
|
||||
public VirtualClock Clock { get; }
|
||||
public OfficeInstallDetector Sut { get; }
|
||||
public OfficeC2RSnapshot Current { get; set; }
|
||||
private readonly TempDirectory _tmp;
|
||||
|
||||
public Rig(OfficeC2RSnapshot initial)
|
||||
{
|
||||
_tmp = new TempDirectory();
|
||||
Sink = new FakeSignalIngressSink();
|
||||
Clock = new VirtualClock(At);
|
||||
Current = initial;
|
||||
var post = new InformationalEventPost(Sink, Clock);
|
||||
Sut = new OfficeInstallDetector("S1", "T1", post, NewLogger(_tmp.Path), Clock)
|
||||
{
|
||||
SnapshotProvider = () => Current,
|
||||
};
|
||||
}
|
||||
|
||||
public List<FakeSignalIngressSink.PostedSignal> OfficeEvents() => Sink.Posted
|
||||
.Where(p => p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et != null && et.StartsWith("office_install_", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
}
|
||||
|
||||
private static OfficeC2RSnapshot ActiveStreaming()
|
||||
{
|
||||
var s = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
Channel = "Current",
|
||||
Platform = "x64",
|
||||
StreamingFinished = false,
|
||||
ActiveScenarioPresent = true,
|
||||
ActiveScenarioName = "INSTALL",
|
||||
OfficeC2RClientRunning = true,
|
||||
};
|
||||
s.Products.Add("O365ProPlusRetail");
|
||||
return s;
|
||||
}
|
||||
|
||||
private static OfficeC2RSnapshot CompletedIdle(string version = "16.0.17628.20144")
|
||||
{
|
||||
var s = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
Channel = "Current",
|
||||
Platform = "x64",
|
||||
StreamingFinished = true,
|
||||
ActiveScenarioPresent = false,
|
||||
ActiveScenarioName = null,
|
||||
OfficeC2RClientRunning = false,
|
||||
VersionToReport = version,
|
||||
};
|
||||
s.Products.Add("O365ProPlusRetail");
|
||||
return s;
|
||||
}
|
||||
|
||||
private static string EventType(FakeSignalIngressSink.PostedSignal p) => p.Payload![SignalPayloadKeys.EventType];
|
||||
private static string Severity(FakeSignalIngressSink.PostedSignal p) => p.Payload![SignalPayloadKeys.Severity];
|
||||
private static Dictionary<string, object> Data(FakeSignalIngressSink.PostedSignal p)
|
||||
=> Assert.IsType<Dictionary<string, object>>(p.TypedPayload);
|
||||
|
||||
// ---------------------------------------------------------------- idle guard
|
||||
|
||||
[Fact]
|
||||
public void Events_only_after_worker_started()
|
||||
{
|
||||
using var rig = new Rig(CompletedIdle());
|
||||
|
||||
// No worker-start signal yet → registry/DO/stop are no-ops.
|
||||
rig.Sut.OnRegistryChanged();
|
||||
rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 50 });
|
||||
rig.Sut.OnWorkerStopped();
|
||||
|
||||
Assert.Empty(rig.OfficeEvents());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- started
|
||||
|
||||
[Fact]
|
||||
public void Worker_started_emits_exactly_one_started_with_products_and_phase()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
var started = Assert.Single(rig.OfficeEvents());
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(started));
|
||||
Assert.Equal("Info", Severity(started));
|
||||
|
||||
var data = Data(started);
|
||||
Assert.Equal("Streaming", data["phase"]);
|
||||
Assert.Equal("INSTALL", data["scenario"]);
|
||||
Assert.Contains("O365ProPlusRetail", (List<string>)data["products"]);
|
||||
Assert.Equal("Current", data["channel"]);
|
||||
Assert.Equal("x64", data["platform"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Second_worker_started_is_ignored_one_lifecycle()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
rig.Sut.OnWorkerStarted(); // already Active — must not emit a second started
|
||||
|
||||
Assert.Single(rig.OfficeEvents());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- progress (no heartbeat)
|
||||
|
||||
[Fact]
|
||||
public void Registry_change_without_real_delta_emits_no_progress_heartbeat()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
|
||||
rig.Sut.OnWorkerStarted(); // started
|
||||
rig.Sut.OnRegistryChanged(); // identical snapshot — must NOT emit progress
|
||||
rig.Sut.OnRegistryChanged();
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Single(events);
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(events[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenario_value_change_emits_one_progress_event()
|
||||
{
|
||||
var s1 = ActiveStreaming();
|
||||
s1.ScenarioValues["INSTALL\\CurrentOperation"] = "Downloading";
|
||||
using var rig = new Rig(s1);
|
||||
|
||||
rig.Sut.OnWorkerStarted(); // started
|
||||
|
||||
var s2 = ActiveStreaming();
|
||||
s2.ScenarioValues["INSTALL\\CurrentOperation"] = "Applying"; // changed value
|
||||
rig.Current = s2;
|
||||
rig.Sut.OnRegistryChanged(); // progress
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallProgress, EventType(events[1]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- DO folding
|
||||
|
||||
[Fact]
|
||||
public void Office_do_sample_folds_download_percent_into_progress()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
|
||||
rig.Sut.OnWorkerStarted(); // started
|
||||
|
||||
rig.Sut.OnOfficeDoSample(new OfficeDoSample
|
||||
{
|
||||
JobCount = 2,
|
||||
FileSize = 1000,
|
||||
TotalBytesDownloaded = 250,
|
||||
BytesFromPeers = 100,
|
||||
BytesFromHttp = 150,
|
||||
PercentPeerCaching = 40,
|
||||
DownloadMode = 1,
|
||||
});
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Equal(2, events.Count);
|
||||
var progress = events[1];
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallProgress, EventType(progress));
|
||||
|
||||
var data = Data(progress);
|
||||
Assert.Equal(250L, data["doTotalBytesDownloaded"]);
|
||||
Assert.Equal(25, data["downloadPercent"]);
|
||||
Assert.Equal(2, data["doJobCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_identical_do_sample_emits_no_progress()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
var sample = new OfficeDoSample { JobCount = 1, FileSize = 1000, TotalBytesDownloaded = 250 };
|
||||
rig.Sut.OnOfficeDoSample(sample); // progress (bytes advanced)
|
||||
rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 1, FileSize = 1000, TotalBytesDownloaded = 250 }); // same bytes — no progress
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Equal(2, events.Count); // started + one progress only
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- completed
|
||||
|
||||
[Fact]
|
||||
public void Worker_stopped_with_streaming_finished_emits_completed_with_version_and_duration()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
|
||||
rig.Sut.OnWorkerStarted(); // started
|
||||
rig.Clock.Advance(TimeSpan.FromSeconds(412));
|
||||
rig.Current = CompletedIdle("16.0.17628.20144");
|
||||
rig.Sut.OnWorkerStopped(); // completed
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Equal(2, events.Count);
|
||||
var completed = events[1];
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(completed));
|
||||
Assert.Equal("Info", Severity(completed));
|
||||
|
||||
var data = Data(completed);
|
||||
Assert.Equal("Completed", data["phase"]);
|
||||
Assert.Equal("16.0.17628.20144", data["versionReached"]);
|
||||
Assert.Equal(412, data["durationSeconds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_state_latches_no_further_events()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
rig.Current = CompletedIdle();
|
||||
rig.Sut.OnWorkerStopped(); // completed
|
||||
rig.Current = ActiveStreaming();
|
||||
rig.Sut.OnWorkerStarted(); // must NOT re-trigger (one lifecycle)
|
||||
rig.Sut.OnRegistryChanged();
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(events[1]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- failed
|
||||
|
||||
[Fact]
|
||||
public void Worker_stopped_without_streaming_finished_emits_failed_warning()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
rig.Current = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
StreamingFinished = false,
|
||||
ActiveScenarioPresent = false,
|
||||
OfficeC2RClientRunning = false,
|
||||
};
|
||||
rig.Sut.OnWorkerStopped(); // failed (abandoned)
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallFailed, EventType(events[1]));
|
||||
Assert.Equal("Warning", Severity(events[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Observed_error_code_during_progress_emits_failed_error_with_code()
|
||||
{
|
||||
using var rig = new Rig(ActiveStreaming());
|
||||
|
||||
rig.Sut.OnWorkerStarted();
|
||||
|
||||
var withError = ActiveStreaming();
|
||||
withError.ErrorCode = "0x80070005";
|
||||
rig.Current = withError;
|
||||
rig.Sut.OnRegistryChanged(); // failed (error code)
|
||||
|
||||
var events = rig.OfficeEvents();
|
||||
Assert.Equal(2, events.Count);
|
||||
var failed = events[1];
|
||||
Assert.Equal(Constants.EventTypes.OfficeInstallFailed, EventType(failed));
|
||||
Assert.Equal("Error", Severity(failed));
|
||||
Assert.Equal("0x80070005", Data(failed)["errorCode"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- error-code heuristic
|
||||
|
||||
[Theory]
|
||||
[InlineData("0x80070005", true)] // hex HRESULT
|
||||
[InlineData("17002", true)] // decimal C2R exit code
|
||||
[InlineData("-2147024891", true)] // negative decimal HRESULT
|
||||
[InlineData("0x0", false)] // zero hex
|
||||
[InlineData("0", false)] // zero decimal
|
||||
[InlineData("Success", false)] // benign textual value (e.g. Result=Success)
|
||||
[InlineData("Completed", false)]
|
||||
[InlineData("InProgress", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsNonZeroNumericCode_only_treats_nonzero_numbers_as_errors(string? value, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, OfficeInstallDetector.IsNonZeroNumericCode(value!));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ctor
|
||||
|
||||
[Fact]
|
||||
public void Ctor_rejects_null_dependencies()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp.Path);
|
||||
var clock = new VirtualClock(At);
|
||||
var post = new InformationalEventPost(new FakeSignalIngressSink(), clock);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => new OfficeInstallDetector(null!, "T1", post, logger, clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new OfficeInstallDetector("S1", "T1", post, logger, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// Surfaces the REAL Microsoft 365 Apps (Office Click-to-Run / C2R) background install lifecycle
|
||||
/// — even when the Intune "integrated" Office app reports done to IME within 1-2 minutes while C2R
|
||||
/// keeps streaming/laying down the product for many more minutes.
|
||||
/// <para>
|
||||
/// <b>Event-driven, no idle polling</b> (Rev 2). This class is the pure decision core; the
|
||||
/// <c>OfficeInstallDetectorHost</c> owns the OS watchers and drives it:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="OnWorkerStarted"/> — Office C2R worker (OfficeC2RClient.exe) started
|
||||
/// (WMI <c>Win32_ProcessStartTrace</c> push or startup probe) → emit
|
||||
/// <see cref="Constants.EventTypes.OfficeInstallStarted"/>.</item>
|
||||
/// <item><see cref="OnRegistryChanged"/> — <c>RegNotifyChangeKeyValue</c> push on the ClickToRun
|
||||
/// key (Configuration value / Scenario subkey churn) → emit
|
||||
/// <see cref="Constants.EventTypes.OfficeInstallProgress"/> on real change only (no heartbeat).</item>
|
||||
/// <item><see cref="OnOfficeDoSample"/> — aggregated Delivery-Optimization stats for Office's CDN
|
||||
/// download (from the extended DeliveryOptimizationCollector). DO has no push API, so it is
|
||||
/// sampled while the worker is alive; a byte advance folds a real download-% into progress.</item>
|
||||
/// <item><see cref="OnWorkerStopped"/> — last worker exited (<see cref="Process.Exited"/>) →
|
||||
/// emit <see cref="Constants.EventTypes.OfficeInstallCompleted"/> /
|
||||
/// <see cref="Constants.EventTypes.OfficeInstallFailed"/> with version + duration + DO summary.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registry reads use the forced Registry64 view (AnyCPU net48 may otherwise read the stale
|
||||
/// WOW6432Node mirror). One install lifecycle per detector (latches Terminal); a later C2R update
|
||||
/// in the same session is out of scope for v1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class OfficeInstallDetector
|
||||
{
|
||||
internal const string SourceName = "OfficeInstallDetector";
|
||||
internal const string AppName = "Microsoft 365 Apps";
|
||||
|
||||
private const string ConfigurationSubKey = @"SOFTWARE\Microsoft\Office\ClickToRun\Configuration";
|
||||
private const string ScenarioSubKey = @"SOFTWARE\Microsoft\Office\ClickToRun\Scenario";
|
||||
|
||||
// Value-name substrings scanned (case-insensitive) for a best-effort failure code in the
|
||||
// undocumented Scenario value set. Deliberately NARROW (no bare "result" — that would match a
|
||||
// benign "Result=Success"); a hit is only treated as a failure when the VALUE is a non-zero
|
||||
// numeric/hex code (see IsNonZeroNumericCode). TODO(office-followup): validate the real value
|
||||
// names against a field enrollment and widen only if needed.
|
||||
private static readonly string[] ErrorValueHints = { "errorcode", "hresult", "exitcode", "lasterror", "failurecode" };
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly object _lock = new object();
|
||||
|
||||
// Test seam: when set, used instead of reading the live registry/process state. Production
|
||||
// never assigns it. Surfaced to the test assembly via InternalsVisibleTo.
|
||||
internal Func<OfficeC2RSnapshot>? SnapshotProvider { get; set; }
|
||||
|
||||
private enum DetectorState { Idle, Active, Terminal }
|
||||
|
||||
private DetectorState _state = DetectorState.Idle;
|
||||
private DateTime? _startedAtUtc;
|
||||
private string? _lastProgressSignature;
|
||||
private OfficeDoSample? _lastDo;
|
||||
|
||||
public OfficeInstallDetector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
IClock clock)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event entry points (driven by the host's process + registry watchers)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Office C2R worker started — open the install window.</summary>
|
||||
public void OnWorkerStarted()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state != DetectorState.Idle) return;
|
||||
var snap = ReadSnapshotSafe();
|
||||
if (snap == null) return;
|
||||
|
||||
_state = DetectorState.Active;
|
||||
_startedAtUtc = _clock.UtcNow;
|
||||
_lastProgressSignature = BuildProgressSignature(snap, _lastDo);
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallStarted, snap, EventSeverity.Info, PhaseOf(snap), isTerminal: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ClickToRun registry changed — progress or a discovered error code.</summary>
|
||||
public void OnRegistryChanged()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state != DetectorState.Active) return;
|
||||
var snap = ReadSnapshotSafe();
|
||||
if (snap == null) return;
|
||||
EvaluateActive(snap);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Latest aggregated Office DO stats — folds a real download-% into progress.</summary>
|
||||
public void OnOfficeDoSample(OfficeDoSample sample)
|
||||
{
|
||||
if (sample == null) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_lastDo = sample;
|
||||
if (_state != DetectorState.Active) return;
|
||||
var snap = ReadSnapshotSafe();
|
||||
if (snap == null) return;
|
||||
EvaluateActive(snap);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Last Office C2R worker exited — resolve terminal completed / failed.</summary>
|
||||
public void OnWorkerStopped()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state != DetectorState.Active) return;
|
||||
var snap = ReadSnapshotSafe();
|
||||
if (snap == null) snap = new OfficeC2RSnapshot();
|
||||
Finalize(snap);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State machine
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void EvaluateActive(OfficeC2RSnapshot snap)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(snap.ErrorCode))
|
||||
{
|
||||
Finalize(snap);
|
||||
return;
|
||||
}
|
||||
|
||||
var sig = BuildProgressSignature(snap, _lastDo);
|
||||
if (!string.Equals(sig, _lastProgressSignature, StringComparison.Ordinal))
|
||||
{
|
||||
_lastProgressSignature = sig;
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallProgress, snap, EventSeverity.Info, PhaseOf(snap), isTerminal: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void Finalize(OfficeC2RSnapshot snap)
|
||||
{
|
||||
if (_state == DetectorState.Terminal) return;
|
||||
_state = DetectorState.Terminal;
|
||||
|
||||
string eventType;
|
||||
EventSeverity severity;
|
||||
string phase;
|
||||
if (!string.IsNullOrEmpty(snap.ErrorCode))
|
||||
{
|
||||
eventType = Constants.EventTypes.OfficeInstallFailed; severity = EventSeverity.Error; phase = "Failed";
|
||||
}
|
||||
else if (snap.StreamingFinished)
|
||||
{
|
||||
eventType = Constants.EventTypes.OfficeInstallCompleted; severity = EventSeverity.Info; phase = "Completed";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Worker gone without StreamingFinished → abandoned install.
|
||||
eventType = Constants.EventTypes.OfficeInstallFailed; severity = EventSeverity.Warning; phase = "Failed";
|
||||
}
|
||||
|
||||
EmitLifecycle(eventType, snap, severity, phase, isTerminal: true);
|
||||
}
|
||||
|
||||
private static string PhaseOf(OfficeC2RSnapshot snap)
|
||||
=> snap.StreamingFinished ? "Finalizing" : "Streaming";
|
||||
|
||||
/// <summary>
|
||||
/// Stable signature of everything we treat as "progress". A change in any of these (registry
|
||||
/// phase/version/scenario-value, or a DO byte advance) emits one progress event; an unchanged
|
||||
/// signature emits nothing — this keeps the detector heartbeat-free.
|
||||
/// </summary>
|
||||
private static string BuildProgressSignature(OfficeC2RSnapshot snap, OfficeDoSample? doSample)
|
||||
{
|
||||
var parts = new List<string>
|
||||
{
|
||||
"phase=" + PhaseOf(snap),
|
||||
"streaming=" + snap.StreamingFinished,
|
||||
"version=" + (snap.VersionToReport ?? string.Empty),
|
||||
"scenario=" + (snap.ActiveScenarioName ?? string.Empty),
|
||||
};
|
||||
if (snap.ScenarioValues != null && snap.ScenarioValues.Count > 0)
|
||||
{
|
||||
foreach (var kv in snap.ScenarioValues.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
parts.Add(kv.Key + "=" + kv.Value);
|
||||
}
|
||||
if (doSample != null)
|
||||
{
|
||||
parts.Add("doBytes=" + doSample.TotalBytesDownloaded);
|
||||
parts.Add("doPct=" + doSample.DownloadPercent);
|
||||
}
|
||||
return string.Join("|", parts);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Payload
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void EmitLifecycle(string eventType, OfficeC2RSnapshot snap, EventSeverity severity, string phase, bool isTerminal)
|
||||
{
|
||||
var data = BuildPayload(snap, phase, isTerminal, _lastDo);
|
||||
var message = BuildMessage(eventType, snap, phase, _lastDo);
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = SourceName,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
|
||||
internal Dictionary<string, object> BuildPayload(OfficeC2RSnapshot snap, string phase, bool isTerminal, OfficeDoSample? doSample)
|
||||
{
|
||||
var elapsedSeconds = _startedAtUtc.HasValue
|
||||
? Math.Max(0, (int)(_clock.UtcNow - _startedAtUtc.Value).TotalSeconds)
|
||||
: 0;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "appName", AppName },
|
||||
{ "products", snap.Products ?? new List<string>() },
|
||||
{ "channel", snap.Channel ?? string.Empty },
|
||||
{ "platform", snap.Platform ?? string.Empty },
|
||||
{ "scenario", snap.ActiveScenarioName ?? string.Empty },
|
||||
{ "phase", phase },
|
||||
{ "versionReached", snap.VersionToReport ?? string.Empty },
|
||||
{ "streamingFinished", snap.StreamingFinished },
|
||||
{ "officeC2RClientRunning", snap.OfficeC2RClientRunning },
|
||||
{ "errorCode", (object?)snap.ErrorCode ?? string.Empty },
|
||||
{ "scanErrors", (snap.Errors ?? new List<string>()).ToList() },
|
||||
};
|
||||
|
||||
if (doSample != null)
|
||||
{
|
||||
// Delivery Optimization download telemetry for Office's CDN content (folded in to
|
||||
// avoid creating a phantom app in the backend AppInstallSummary — Office is not an IME app).
|
||||
data["doJobCount"] = doSample.JobCount;
|
||||
data["doFileSize"] = doSample.FileSize;
|
||||
data["doTotalBytesDownloaded"] = doSample.TotalBytesDownloaded;
|
||||
data["doBytesFromPeers"] = doSample.BytesFromPeers;
|
||||
data["doBytesFromHttp"] = doSample.BytesFromHttp;
|
||||
data["doBytesFromCacheServer"] = doSample.BytesFromCacheServer;
|
||||
data["doPercentPeerCaching"] = doSample.PercentPeerCaching;
|
||||
data["doDownloadMode"] = doSample.DownloadMode;
|
||||
if (doSample.DownloadPercent.HasValue)
|
||||
data["downloadPercent"] = doSample.DownloadPercent.Value;
|
||||
}
|
||||
|
||||
if (isTerminal)
|
||||
data["durationSeconds"] = elapsedSeconds;
|
||||
else
|
||||
data["elapsedSeconds"] = elapsedSeconds;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static string BuildMessage(string eventType, OfficeC2RSnapshot snap, string phase, OfficeDoSample? doSample)
|
||||
{
|
||||
var product = snap.Products != null && snap.Products.Count > 0 ? string.Join(",", snap.Products) : AppName;
|
||||
var pct = doSample?.DownloadPercent;
|
||||
if (eventType == Constants.EventTypes.OfficeInstallStarted)
|
||||
return $"{SourceName}: {product} install detected (phase={phase})";
|
||||
if (eventType == Constants.EventTypes.OfficeInstallProgress)
|
||||
return pct.HasValue
|
||||
? $"{SourceName}: {product} install progress (phase={phase}, download {pct.Value}%)"
|
||||
: $"{SourceName}: {product} install progress (phase={phase})";
|
||||
if (eventType == Constants.EventTypes.OfficeInstallCompleted)
|
||||
return $"{SourceName}: {product} install completed{(string.IsNullOrEmpty(snap.VersionToReport) ? string.Empty : $" (v{snap.VersionToReport})")}";
|
||||
return $"{SourceName}: {product} install failed{(string.IsNullOrEmpty(snap.ErrorCode) ? string.Empty : $" (code {snap.ErrorCode})")}";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Registry + process read. Fail-soft — errors captured, never thrown.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private OfficeC2RSnapshot? ReadSnapshotSafe()
|
||||
{
|
||||
try { return SnapshotProvider != null ? SnapshotProvider() : ReadSnapshot(); }
|
||||
catch (Exception ex) { _logger.Warning($"[{SourceName}] snapshot read failed: {ex.Message}"); return null; }
|
||||
}
|
||||
|
||||
internal virtual OfficeC2RSnapshot ReadSnapshot()
|
||||
{
|
||||
var snap = new OfficeC2RSnapshot();
|
||||
try
|
||||
{
|
||||
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
{
|
||||
ReadConfiguration(hklm, snap);
|
||||
ReadScenarios(hklm, snap);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
snap.Errors.Add($"registry:{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
snap.OfficeC2RClientRunning = IsOfficeC2RClientRunning(snap);
|
||||
return snap;
|
||||
}
|
||||
|
||||
private static void ReadConfiguration(RegistryKey hklm, OfficeC2RSnapshot snap)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = hklm.OpenSubKey(ConfigurationSubKey, writable: false))
|
||||
{
|
||||
if (key == null) return;
|
||||
snap.ConfigurationKeyPresent = true;
|
||||
|
||||
var products = ReadString(key, "ProductReleaseIds");
|
||||
if (!string.IsNullOrEmpty(products))
|
||||
{
|
||||
snap.Products = products!
|
||||
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(p => p.Trim())
|
||||
.Where(p => p.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
snap.Platform = ReadString(key, "Platform");
|
||||
snap.Channel = ReadString(key, "UpdateChannel") ?? ReadString(key, "CDNBaseUrl");
|
||||
snap.VersionToReport = ReadString(key, "VersionToReport") ?? ReadString(key, "ClientVersionToReport");
|
||||
snap.StreamingFinished = IsTruthy(ReadString(key, "StreamingFinished"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
snap.Errors.Add($"configuration:{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReadScenarios(RegistryKey hklm, OfficeC2RSnapshot snap)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var scenarioRoot = hklm.OpenSubKey(ScenarioSubKey, writable: false))
|
||||
{
|
||||
if (scenarioRoot == null) return;
|
||||
|
||||
var scenarioNames = scenarioRoot.GetSubKeyNames();
|
||||
if (scenarioNames.Length == 0) return;
|
||||
|
||||
snap.ActiveScenarioPresent = true;
|
||||
snap.ActiveScenarioName = scenarioNames[0]; // primary scenario (INSTALL / FIRSTRUN / UPDATE …)
|
||||
|
||||
foreach (var scenarioName in scenarioNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var scenarioKey = scenarioRoot.OpenSubKey(scenarioName, writable: false))
|
||||
{
|
||||
if (scenarioKey == null) continue;
|
||||
foreach (var valueName in scenarioKey.GetValueNames())
|
||||
{
|
||||
var value = ReadString(scenarioKey, valueName);
|
||||
if (value == null) continue;
|
||||
snap.ScenarioValues[$"{scenarioName}\\{valueName}"] = value;
|
||||
|
||||
if (snap.ErrorCode == null
|
||||
&& ErrorValueHints.Any(h => valueName.IndexOf(h, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
&& IsNonZeroNumericCode(value))
|
||||
{
|
||||
snap.ErrorCode = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
snap.Errors.Add($"scenario[{scenarioName}]:{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
snap.Errors.Add($"scenario:{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsOfficeC2RClientRunning(OfficeC2RSnapshot snap)
|
||||
{
|
||||
Process[]? procs = null;
|
||||
try
|
||||
{
|
||||
procs = Process.GetProcessesByName("OfficeC2RClient");
|
||||
return procs.Length > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
snap.Errors.Add($"process:{ex.GetType().Name}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (procs != null)
|
||||
foreach (var p in procs) { try { p.Dispose(); } catch { } }
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadString(RegistryKey key, string valueName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var v = key.GetValue(valueName);
|
||||
if (v == null) return null;
|
||||
var s = v.ToString();
|
||||
return string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>StreamingFinished is REG_SZ "True"/"False" or REG_DWORD 0/1 across versions.</summary>
|
||||
private static bool IsTruthy(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
var v = value!.Trim();
|
||||
return v == "1" || v.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True only when <paramref name="value"/> is a non-zero numeric or hex (0x…) code. Textual
|
||||
/// values such as "Success" / "Completed" / "InProgress" are NOT error codes and return false,
|
||||
/// so a benign "Result=Success" never masquerades as a failure.
|
||||
/// </summary>
|
||||
internal static bool IsNonZeroNumericCode(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
var v = value.Trim();
|
||||
if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return long.TryParse(v.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hex) && hex != 0;
|
||||
}
|
||||
// Decimal (HRESULTs can be large or negative); reject anything non-numeric.
|
||||
return long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dec) && dec != 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Raw scan model (surfaced to the test assembly via InternalsVisibleTo).
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>One read's worth of Office C2R facts (registry + process).</summary>
|
||||
public sealed class OfficeC2RSnapshot
|
||||
{
|
||||
public bool ConfigurationKeyPresent { get; set; }
|
||||
public List<string> Products { get; set; } = new List<string>();
|
||||
public string? Channel { get; set; }
|
||||
public string? Platform { get; set; }
|
||||
public string? VersionToReport { get; set; }
|
||||
public bool StreamingFinished { get; set; }
|
||||
public bool ActiveScenarioPresent { get; set; }
|
||||
public string? ActiveScenarioName { get; set; }
|
||||
public Dictionary<string, string> ScenarioValues { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
public string? ErrorCode { get; set; }
|
||||
public bool OfficeC2RClientRunning { get; set; }
|
||||
public List<string> Errors { get; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated Delivery-Optimization stats across all Office CDN download jobs in one DO poll,
|
||||
/// produced by the extended DeliveryOptimizationCollector and folded into the office_install_* events.
|
||||
/// </summary>
|
||||
public sealed class OfficeDoSample
|
||||
{
|
||||
public int JobCount { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
public long TotalBytesDownloaded { get; set; }
|
||||
public long BytesFromPeers { get; set; }
|
||||
public long BytesFromHttp { get; set; }
|
||||
public long BytesFromCacheServer { get; set; }
|
||||
public int PercentPeerCaching { get; set; }
|
||||
public int DownloadMode { get; set; }
|
||||
|
||||
/// <summary>Whole-percent download progress, or null when total file size is unknown.</summary>
|
||||
public int? DownloadPercent => FileSize > 0 ? (int?)Math.Min(100, (int)((TotalBytesDownloaded * 100) / FileSize)) : null;
|
||||
}
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// Event-driven watcher for the Office Click-to-Run worker process (<c>OfficeC2RClient.exe</c>),
|
||||
/// the process C2R spawns for an active install/update operation (NOT the always-running
|
||||
/// <c>OfficeClickToRun.exe</c> service). Drives the <see cref="OfficeInstallDetector"/> without any
|
||||
/// idle polling:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Start</b> — a true ETW-backed push via WMI <c>Win32_ProcessStartTrace</c>
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a one-shot startup probe so an install already
|
||||
/// in flight when the agent boots (realistic during OOBE) is still caught.</item>
|
||||
/// <item><b>Stop</b> — <see cref="Process.Exited"/> on each tracked worker (clean OS event, same
|
||||
/// pattern as <c>ImeProcessWatcher</c>).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <see cref="Started"/> fires once when the worker count transitions 0→1; <see cref="Stopped"/>
|
||||
/// once when it transitions back to 0 (multiple short-lived workers during one install collapse to
|
||||
/// a single active window). Fail-soft: if the WMI watcher cannot start, it is logged and the
|
||||
/// startup probe still covers the already-running case — no polling fallback by design.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OfficeProcessWatcher : IDisposable
|
||||
{
|
||||
internal const string WorkerProcessName = "OfficeC2RClient.exe";
|
||||
private const string WorkerProcessNameNoExt = "OfficeC2RClient";
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly int _settleSeconds;
|
||||
private readonly object _lock = new object();
|
||||
private readonly Dictionary<int, Process> _workers = new Dictionary<int, Process>();
|
||||
|
||||
private ManagementEventWatcher? _startWatcher;
|
||||
private Timer? _settleTimer;
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
private bool _active; // "an install window is open" — stays true across short worker gaps (settle)
|
||||
|
||||
/// <summary>Raised once when the first Office worker appears (install window opens).</summary>
|
||||
public event EventHandler? Started;
|
||||
|
||||
/// <summary>
|
||||
/// Raised once when the install window closes — i.e. no worker has run for the settle window.
|
||||
/// C2R spawns several short-lived OfficeC2RClient.exe processes (with small gaps) during one
|
||||
/// install, so the last-worker-exit is debounced to avoid a premature terminal decision.
|
||||
/// </summary>
|
||||
public event EventHandler? Stopped;
|
||||
|
||||
/// <param name="settleSeconds">Grace period after the last worker exits before the window is
|
||||
/// declared closed; a new worker within it keeps the window open. <=0 closes immediately.</param>
|
||||
public OfficeProcessWatcher(AgentLogger logger, int settleSeconds = 10)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_settleSeconds = settleSeconds;
|
||||
}
|
||||
|
||||
/// <summary>True while at least one Office worker is being tracked.</summary>
|
||||
public bool IsActive
|
||||
{
|
||||
get { lock (_lock) { return _active; } }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_started || _disposed) return;
|
||||
_started = true;
|
||||
}
|
||||
|
||||
// 1) Startup probe — catch a worker already running before the watcher armed.
|
||||
try
|
||||
{
|
||||
foreach (var proc in Process.GetProcessesByName(WorkerProcessNameNoExt))
|
||||
{
|
||||
TrackWorker(proc);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[OfficeProcessWatcher] startup probe failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling.
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'");
|
||||
_startWatcher = new ManagementEventWatcher(query);
|
||||
_startWatcher.EventArrived += OnProcessStartTrace;
|
||||
_startWatcher.Start();
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No polling fallback by design — the startup probe already covers the in-flight case.
|
||||
_logger.Warning($"[OfficeProcessWatcher] could not start WMI process-start watcher " +
|
||||
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
|
||||
_startWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProcessStartTrace(object sender, EventArrivedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pidObj = e.NewEvent?["ProcessID"];
|
||||
if (pidObj == null) return;
|
||||
var pid = Convert.ToInt32(pidObj);
|
||||
|
||||
Process? proc = null;
|
||||
try { proc = Process.GetProcessById(pid); }
|
||||
catch { /* already gone — ignore */ }
|
||||
if (proc != null) TrackWorker(proc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[OfficeProcessWatcher] start-trace handler error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void TrackWorker(Process proc)
|
||||
{
|
||||
bool raiseStarted = false;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) { try { proc.Dispose(); } catch { } return; }
|
||||
if (_workers.ContainsKey(proc.Id)) { try { proc.Dispose(); } catch { } return; }
|
||||
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[OfficeProcessWatcher] TrackWorker error: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (raiseStarted)
|
||||
{
|
||||
_logger.Info($"[OfficeProcessWatcher] Office worker started (PID={SafePid(proc)}) — install active");
|
||||
RaiseStarted();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWorkerExited(object? sender, EventArgs e)
|
||||
{
|
||||
if (!(sender is Process proc)) return;
|
||||
bool raiseStoppedNow = false;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_workers.Remove(SafePid(proc)))
|
||||
{
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
}
|
||||
if (_active && _workers.Count == 0)
|
||||
{
|
||||
if (_settleSeconds <= 0)
|
||||
{
|
||||
_active = false;
|
||||
raiseStoppedNow = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Debounce: C2R may start another short-lived worker after a small gap. Only
|
||||
// close the window if none reappears within the settle period.
|
||||
_logger.Debug($"[OfficeProcessWatcher] last worker exited — settling {_settleSeconds}s before closing the install window");
|
||||
StartSettleTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (raiseStoppedNow)
|
||||
{
|
||||
_logger.Info("[OfficeProcessWatcher] last Office worker exited — install window closed");
|
||||
RaiseStopped();
|
||||
}
|
||||
}
|
||||
|
||||
// Settle-window helpers — all callers hold _lock except the timer callback, which re-locks.
|
||||
private void StartSettleTimer()
|
||||
{
|
||||
_settleTimer?.Dispose();
|
||||
_settleTimer = new Timer(OnSettleElapsed, null, TimeSpan.FromSeconds(_settleSeconds), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
private void CancelSettleTimer()
|
||||
{
|
||||
_settleTimer?.Dispose();
|
||||
_settleTimer = null;
|
||||
}
|
||||
|
||||
private void OnSettleElapsed(object? state)
|
||||
{
|
||||
bool raiseStopped = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_settleTimer?.Dispose();
|
||||
_settleTimer = null;
|
||||
// A worker may have reappeared during settle (which cancels the timer); re-check.
|
||||
if (_active && _workers.Count == 0)
|
||||
{
|
||||
_active = false;
|
||||
raiseStopped = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (raiseStopped)
|
||||
{
|
||||
_logger.Info($"[OfficeProcessWatcher] no Office worker for {_settleSeconds}s — install window closed");
|
||||
RaiseStopped();
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseStarted()
|
||||
{
|
||||
try { Started?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"[OfficeProcessWatcher] Started handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void RaiseStopped()
|
||||
{
|
||||
try { Stopped?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"[OfficeProcessWatcher] Stopped handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static int SafePid(Process proc)
|
||||
{
|
||||
try { return proc.Id; } catch { return -1; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
if (_startWatcher != null)
|
||||
{
|
||||
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { _startWatcher.Stop(); } catch { }
|
||||
try { _startWatcher.Dispose(); } catch { }
|
||||
_startWatcher = null;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
CancelSettleTimer();
|
||||
foreach (var kv in _workers)
|
||||
{
|
||||
try { kv.Value.Exited -= OnWorkerExited; } catch { }
|
||||
try { kv.Value.Dispose(); } catch { }
|
||||
}
|
||||
_workers.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// Push-based registry-change notifier built on <c>RegNotifyChangeKeyValue</c> (async). Signals
|
||||
/// <see cref="Changed"/> whenever a value is set or a subkey is added/removed anywhere under the
|
||||
/// target key (watch-subtree) — used by the <see cref="OfficeInstallDetector"/> to react to C2R
|
||||
/// <c>Configuration</c> value changes (VersionToReport / StreamingFinished) and <c>Scenario</c>
|
||||
/// subkey churn without polling.
|
||||
/// <para>
|
||||
/// <b>Bootstrap fallback</b>: on a clean first install the target key (e.g.
|
||||
/// <c>…\Office\ClickToRun</c>) may not exist yet when the C2R worker starts. Instead of giving up,
|
||||
/// the watcher arms a <em>cheap</em> watch on the nearest existing ancestor — <b>non-recursive</b>
|
||||
/// (<c>bWatchSubtree=FALSE</c>) and <b>name-only</b> (<c>REG_NOTIFY_CHANGE_NAME</c>) — so it fires
|
||||
/// ONLY when a direct child key is created/deleted there (rare; it ignores the noisy value churn in
|
||||
/// the subtree below). When the target finally appears it promotes to the full target watch and
|
||||
/// raises one <see cref="Changed"/>. This avoids both "no signal until process end" and the high
|
||||
/// event volume that a recursive watch on a broad parent (e.g. <c>…\Microsoft</c>) would cause.
|
||||
/// </para>
|
||||
/// One-shot per OS contract, so it re-arms after each fire. Fail-soft: any failure is logged and
|
||||
/// the watcher stays quiet (the detector still gets the process Started/Stopped boundary events).
|
||||
/// </summary>
|
||||
public sealed class RegistryChangeWatcher : IDisposable
|
||||
{
|
||||
[Flags]
|
||||
private enum RegNotifyFilter : uint
|
||||
{
|
||||
ChangeName = 0x00000001, // subkey added/removed (ClickToRun appearing; Scenario\* churn)
|
||||
ChangeLastSet = 0x00000004, // value set (Configuration: VersionToReport / StreamingFinished)
|
||||
}
|
||||
|
||||
private const RegNotifyFilter PrimaryFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet;
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName; // cheap: direct-child create/delete only
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern int RegNotifyChangeKeyValue(
|
||||
SafeRegistryHandle hKey,
|
||||
[MarshalAs(UnmanagedType.Bool)] bool bWatchSubtree,
|
||||
RegNotifyFilter dwNotifyFilter,
|
||||
SafeWaitHandle hEvent,
|
||||
[MarshalAs(UnmanagedType.Bool)] bool fAsynchronous);
|
||||
|
||||
private enum WatchMode { None, Primary, Bootstrap }
|
||||
|
||||
private readonly string _targetSubKey;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly object _lock = new object();
|
||||
|
||||
private RegistryKey? _baseKey;
|
||||
private RegistryKey? _watchedKey;
|
||||
private WatchMode _mode;
|
||||
private AutoResetEvent? _signal;
|
||||
private RegisteredWaitHandle? _registeredWait;
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Raised (on a ThreadPool thread) when the target subtree meaningfully changes.</summary>
|
||||
public event EventHandler? Changed;
|
||||
|
||||
/// <param name="targetSubKey">HKLM-relative key, e.g. <c>SOFTWARE\Microsoft\Office\ClickToRun</c>.</param>
|
||||
public RegistryChangeWatcher(string targetSubKey, AgentLogger logger)
|
||||
{
|
||||
_targetSubKey = targetSubKey ?? throw new ArgumentNullException(nameof(targetSubKey));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_started || _disposed) return;
|
||||
_started = true;
|
||||
try
|
||||
{
|
||||
// Forced Registry64 view — consistent with the C2R reads (AnyCPU net48 may otherwise
|
||||
// resolve the stale WOW6432Node mirror).
|
||||
_baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
_signal = new AutoResetEvent(false);
|
||||
ArmBestEffort(raiseOnPromote: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on HKLM\\{_targetSubKey}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arms the best available watch: the target key (full, subtree) if it exists, otherwise a cheap
|
||||
/// name-only watch on the nearest existing ancestor until the target appears. Must hold <see cref="_lock"/>.
|
||||
/// </summary>
|
||||
private void ArmBestEffort(bool raiseOnPromote)
|
||||
{
|
||||
if (_disposed || _baseKey == null) return;
|
||||
|
||||
CloseWatchedKey();
|
||||
|
||||
var target = TryOpen(_targetSubKey);
|
||||
if (target != null)
|
||||
{
|
||||
_watchedKey = target;
|
||||
_mode = WatchMode.Primary;
|
||||
if (!ArmNotify(subtree: true, PrimaryFilter)) return;
|
||||
if (raiseOnPromote) RaiseChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Target absent — watch the nearest existing ancestor (non-recursive, name-only = cheap).
|
||||
var ancestor = OpenNearestExistingAncestor(out var ancestorPath);
|
||||
if (ancestor == null)
|
||||
{
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for HKLM\\{_targetSubKey}; not arming");
|
||||
return;
|
||||
}
|
||||
_watchedKey = ancestor;
|
||||
_mode = WatchMode.Bootstrap;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target HKLM\\{_targetSubKey} absent — bootstrap-watching HKLM\\{ancestorPath} (name-only)");
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return;
|
||||
|
||||
// TOCTOU: the target may have been created between TryOpen and arming the ancestor watch.
|
||||
using (var recheck = TryOpen(_targetSubKey))
|
||||
{
|
||||
if (recheck != null) ArmBestEffort(raiseOnPromote: true);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ArmNotify(bool subtree, RegNotifyFilter filter)
|
||||
{
|
||||
if (_disposed || _watchedKey == null || _signal == null) return false;
|
||||
try
|
||||
{
|
||||
int rc = RegNotifyChangeKeyValue(_watchedKey.Handle, subtree, filter, _signal.SafeWaitHandle, fAsynchronous: true);
|
||||
if (rc != 0)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} (HKLM\\{_targetSubKey})");
|
||||
return false;
|
||||
}
|
||||
UnregisterWait();
|
||||
_registeredWait = ThreadPool.RegisterWaitForSingleObject(
|
||||
_signal, OnSignalled, state: null, millisecondsTimeOutInterval: Timeout.Infinite, executeOnlyOnce: true);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed (HKLM\\{_targetSubKey}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSignalled(object? state, bool timedOut)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
UnregisterWait();
|
||||
if (_disposed || timedOut) return;
|
||||
|
||||
if (_mode == WatchMode.Primary)
|
||||
{
|
||||
RaiseChanged();
|
||||
ArmNotify(subtree: true, PrimaryFilter); // re-arm on the same target key
|
||||
}
|
||||
else
|
||||
{
|
||||
// A direct child changed under the ancestor — promote to the target watch if it now
|
||||
// exists (raises Changed on promotion), else keep the cheap bootstrap watch.
|
||||
ArmBestEffort(raiseOnPromote: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private RegistryKey? TryOpen(string subKey)
|
||||
{
|
||||
try { return _baseKey?.OpenSubKey(subKey, writable: false); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Walks up the target path and opens the first ancestor that exists.</summary>
|
||||
private RegistryKey? OpenNearestExistingAncestor(out string ancestorPath)
|
||||
{
|
||||
ancestorPath = string.Empty;
|
||||
var path = _targetSubKey;
|
||||
while (true)
|
||||
{
|
||||
var idx = path.LastIndexOf('\\');
|
||||
if (idx <= 0) return null;
|
||||
path = path.Substring(0, idx);
|
||||
var key = TryOpen(path);
|
||||
if (key != null) { ancestorPath = path; return key; }
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseChanged()
|
||||
{
|
||||
var handler = Changed;
|
||||
if (handler == null) return;
|
||||
try { handler(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"[RegistryChangeWatcher] Changed handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void UnregisterWait()
|
||||
{
|
||||
try { _registeredWait?.Unregister(null); } catch { }
|
||||
_registeredWait = null;
|
||||
}
|
||||
|
||||
private void CloseWatchedKey()
|
||||
{
|
||||
try { _watchedKey?.Dispose(); } catch { }
|
||||
_watchedKey = null;
|
||||
_mode = WatchMode.None;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
UnregisterWait();
|
||||
try { _signal?.Set(); } catch { } // release any pending wait
|
||||
try { _signal?.Dispose(); } catch { }
|
||||
_signal = null;
|
||||
CloseWatchedKey();
|
||||
try { _baseKey?.Dispose(); } catch { }
|
||||
_baseKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
-19
@@ -15,6 +15,7 @@ using Newtonsoft.Json.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
@@ -36,6 +37,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
private readonly Action<AppPackageState> _onDoTelemetryReceived;
|
||||
private readonly string _logFilePath;
|
||||
|
||||
// Office C2R DO support: while an Office worker is active we keep polling even with no IME
|
||||
// downloads, classify the non-IME Office-CDN jobs and hand aggregated stats to the
|
||||
// OfficeInstallDetector (which folds them into the office_install_* events). Office is not an
|
||||
// IME app, so we deliberately do NOT emit download_progress/do_telemetry for it (that would
|
||||
// create a phantom app in the backend AppInstallSummary).
|
||||
private readonly Action<OfficeDoSample> _onOfficeDoSample;
|
||||
private volatile bool _officeActive;
|
||||
|
||||
private Runspace _runspace;
|
||||
private bool _permanentlyDisabled;
|
||||
private int _consecutiveErrors;
|
||||
@@ -59,12 +68,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
int intervalSeconds,
|
||||
Func<AppPackageStateList> getPackageStates,
|
||||
Action<AppPackageState> onDoTelemetryReceived,
|
||||
string logDirectory)
|
||||
string logDirectory,
|
||||
Action<OfficeDoSample> onOfficeDoSample = null)
|
||||
: base(sessionId, tenantId, post, logger, intervalSeconds)
|
||||
{
|
||||
_getPackageStates = getPackageStates ?? throw new ArgumentNullException(nameof(getPackageStates));
|
||||
_onDoTelemetryReceived = onDoTelemetryReceived;
|
||||
_logFilePath = Path.Combine(logDirectory, LogFileName);
|
||||
_onOfficeDoSample = onOfficeDoSample;
|
||||
}
|
||||
|
||||
/// <summary>Start dormant — timer does not fire until WakeUp() is called.</summary>
|
||||
@@ -93,6 +104,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
ResumeTimer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Office C2R wake source: the OfficeProcessWatcher reports an Office worker started/stopped.
|
||||
/// While active, the collector keeps polling (even with no IME downloads) so it can capture
|
||||
/// Office's DO jobs. Called from any thread.
|
||||
/// </summary>
|
||||
public void NotifyOfficeActive(bool active)
|
||||
{
|
||||
_officeActive = active;
|
||||
if (active)
|
||||
{
|
||||
Logger.Info("[DeliveryOptimizationCollector] Office install active — sampling DO for Office CDN jobs");
|
||||
WakeUp();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
if (_permanentlyDisabled || _dormant) return;
|
||||
@@ -116,29 +142,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
private void CollectCore()
|
||||
{
|
||||
|
||||
// Guard: check if there's still work to do
|
||||
// Guard: check if there's still work to do. Office C2R has no IME packages, so an active
|
||||
// Office install (from the OfficeProcessWatcher) keeps the collector polling on its own.
|
||||
var packageStates = _getPackageStates();
|
||||
if (packageStates == null || packageStates.Count == 0) return;
|
||||
|
||||
var hasActiveDownloads = false;
|
||||
var hasPendingEnrichment = false;
|
||||
for (int i = 0; i < packageStates.Count; i++)
|
||||
if (packageStates != null)
|
||||
{
|
||||
var p = packageStates[i];
|
||||
if (p.InstallationState == AppInstallationState.Downloading ||
|
||||
p.InstallationState == AppInstallationState.Installing)
|
||||
hasActiveDownloads = true;
|
||||
for (int i = 0; i < packageStates.Count; i++)
|
||||
{
|
||||
var p = packageStates[i];
|
||||
if (p.InstallationState == AppInstallationState.Downloading ||
|
||||
p.InstallationState == AppInstallationState.Installing)
|
||||
hasActiveDownloads = true;
|
||||
|
||||
if (!p.HasDoTelemetry && !_enrichedAppIds.Contains(p.Id) &&
|
||||
p.DownloadingOrInstallingSeen &&
|
||||
(p.InstallationState == AppInstallationState.Installed ||
|
||||
p.InstallationState == AppInstallationState.Error))
|
||||
hasPendingEnrichment = true;
|
||||
if (!p.HasDoTelemetry && !_enrichedAppIds.Contains(p.Id) &&
|
||||
p.DownloadingOrInstallingSeen &&
|
||||
(p.InstallationState == AppInstallationState.Installed ||
|
||||
p.InstallationState == AppInstallationState.Error))
|
||||
hasPendingEnrichment = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasActiveDownloads && !hasPendingEnrichment)
|
||||
if (!hasActiveDownloads && !hasPendingEnrichment && !_officeActive)
|
||||
{
|
||||
Logger.Info("[DeliveryOptimizationCollector] Going dormant — no active downloads or pending enrichment");
|
||||
Logger.Info("[DeliveryOptimizationCollector] Going dormant — no active downloads, pending enrichment or Office install");
|
||||
_dormant = true;
|
||||
PauseTimer();
|
||||
return;
|
||||
@@ -284,6 +313,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
int progressCount = 0;
|
||||
int matchCount = 0;
|
||||
|
||||
// Office C2R DO aggregation across all Office-CDN jobs in this poll.
|
||||
int officeJobCount = 0;
|
||||
long officeFileSize = 0, officeTotalBytes = 0, officeBytesFromPeers = 0,
|
||||
officeBytesFromHttp = 0, officeBytesFromCacheServer = 0;
|
||||
int officeDownloadMode = -1;
|
||||
|
||||
// Build JSONL snapshot for log (only if fingerprint changes)
|
||||
var logEntries = new JArray();
|
||||
|
||||
@@ -323,10 +358,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
// Try to match to an IME-tracked app
|
||||
var appId = ImeLogTracker.ExtractAppIdFromDoFileId(fileId);
|
||||
if (string.IsNullOrEmpty(appId)) continue;
|
||||
|
||||
var pkg = packageStates.GetPackage(appId);
|
||||
if (pkg == null) continue;
|
||||
var pkg = (!string.IsNullOrEmpty(appId) && packageStates != null)
|
||||
? packageStates.GetPackage(appId)
|
||||
: null;
|
||||
if (pkg == null)
|
||||
{
|
||||
// Not an IME app. While an Office install is active, accumulate the Office CDN
|
||||
// jobs for the OfficeInstallDetector (folded into office_install_*; NOT emitted as
|
||||
// download_progress to avoid a phantom app in the backend AppInstallSummary).
|
||||
if (_officeActive && IsOfficeCdnJob(sourceUrl))
|
||||
{
|
||||
officeJobCount++;
|
||||
officeFileSize += fileSize;
|
||||
officeTotalBytes += totalBytes;
|
||||
officeBytesFromPeers += bytesFromPeers;
|
||||
officeBytesFromHttp += bytesFromHttp;
|
||||
officeBytesFromCacheServer += bytesFromCacheServer;
|
||||
if (downloadMode >= 0) officeDownloadMode = downloadMode;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Live download_progress (every poll where bytes changed) ---
|
||||
long lastBytes;
|
||||
@@ -382,6 +433,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
}
|
||||
}
|
||||
|
||||
// Hand aggregated Office DO stats to the OfficeInstallDetector (folded into office_install_*).
|
||||
if (_officeActive && officeJobCount > 0 && _onOfficeDoSample != null)
|
||||
{
|
||||
int officePeerPct = officeTotalBytes > 0 ? (int)((officeBytesFromPeers * 100) / officeTotalBytes) : 0;
|
||||
try
|
||||
{
|
||||
_onOfficeDoSample(new OfficeDoSample
|
||||
{
|
||||
JobCount = officeJobCount,
|
||||
FileSize = officeFileSize,
|
||||
TotalBytesDownloaded = officeTotalBytes,
|
||||
BytesFromPeers = officeBytesFromPeers,
|
||||
BytesFromHttp = officeBytesFromHttp,
|
||||
BytesFromCacheServer = officeBytesFromCacheServer,
|
||||
PercentPeerCaching = officePeerPct,
|
||||
DownloadMode = officeDownloadMode,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[DeliveryOptimizationCollector] onOfficeDoSample threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Write JSONL log only when overall state changed
|
||||
var fingerprint = ComputeFingerprint(logEntries);
|
||||
if (fingerprint != _lastSnapshotFingerprint)
|
||||
@@ -394,6 +469,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
$"{progressCount} progress updates, {matchCount} new matches ({_enrichedAppIds.Count} total enriched)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a DO job's SourceURL points at the Office CDN (Office C2R content), i.e. a job
|
||||
/// that is not an IME Win32 app. TODO(office-followup): validate the exact SourceURL/FileId
|
||||
/// shape against a real enrollment's do-status.jsonl and tighten if needed.
|
||||
/// </summary>
|
||||
private static bool IsOfficeCdnJob(string sourceUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceUrl)) return false;
|
||||
return sourceUrl.IndexOf("officecdn", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| sourceUrl.IndexOf("officeclient.microsoft.com", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PSObject property helpers (safe extraction, no exceptions on missing props)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -348,9 +348,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
logger: logger,
|
||||
apiBaseUrl: _agentConfig.ApiBaseUrl));
|
||||
|
||||
// M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: only polls when the
|
||||
// IME log tracker reports an app entering Downloading/Installing (see AppStateChanged
|
||||
// chain below). Needs the IME tracker's PackageStates + OnDoTelemetryReceived hook.
|
||||
// Microsoft 365 Apps (Office C2R) install detector — event-driven (Rev 2): woken by a WMI
|
||||
// Win32_ProcessStartTrace push on OfficeC2RClient.exe (+ startup probe), progress via
|
||||
// RegNotifyChangeKeyValue, stop via Process.Exited. No idle polling. Constructed first so
|
||||
// the DO host can share its process-start signal and feed Office DO stats back to it.
|
||||
// Kill-switchable via remote config.
|
||||
OfficeInstallDetectorHost officeHost = null;
|
||||
if (collectors.EnableOfficeInstallDetector)
|
||||
{
|
||||
officeHost = new OfficeInstallDetectorHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
settleSeconds: collectors.OfficeInstallSettleSeconds);
|
||||
}
|
||||
|
||||
// M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: polls only when the IME log
|
||||
// tracker reports an app entering Downloading/Installing (AppStateChanged chain below) OR
|
||||
// an Office C2R install is active (officeHost signal) — the latter lets it capture Office's
|
||||
// DO CDN jobs and fold them into the office_install_* events.
|
||||
if (collectors.EnableDeliveryOptimizationCollector && _imeLogHost != null)
|
||||
{
|
||||
var doHost = new DeliveryOptimizationHost(
|
||||
@@ -360,10 +378,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
intervalSeconds: collectors.DeliveryOptimizationIntervalSeconds,
|
||||
imeHost: _imeLogHost);
|
||||
imeHost: _imeLogHost,
|
||||
officeProcessWatcher: officeHost?.ProcessWatcher,
|
||||
onOfficeDoSample: officeHost != null ? officeHost.SubmitDoSample : (Action<Monitoring.Telemetry.Office.OfficeDoSample>)null);
|
||||
hosts.Add(doHost);
|
||||
}
|
||||
|
||||
// Add the Office host AFTER the DO host so the DO host has subscribed to the process-start
|
||||
// signal before the watcher starts (avoids missing the wake for an install already in flight).
|
||||
if (officeHost != null) hosts.Add(officeHost);
|
||||
|
||||
// M4.6.δ — Gather-rules runtime executor. Runs the backend-defined rules whose
|
||||
// Trigger is "startup" once the agent is up; signal / event / periodic triggers
|
||||
// remain supported inside the executor itself.
|
||||
|
||||
+25
-3
@@ -3,6 +3,7 @@ using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -16,6 +17,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly DeliveryOptimizationCollector _collector;
|
||||
private readonly ImeLogHost _imeHost;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly OfficeProcessWatcher? _officeProcessWatcher;
|
||||
private Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevStateChanged;
|
||||
private Action<AppPackageState, AppInstallationState, AppInstallationState>? _chainedHandler;
|
||||
private int _disposed;
|
||||
@@ -27,12 +29,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
int intervalSeconds,
|
||||
ImeLogHost imeHost)
|
||||
ImeLogHost imeHost,
|
||||
OfficeProcessWatcher? officeProcessWatcher = null,
|
||||
Action<OfficeDoSample>? onOfficeDoSample = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_imeHost = imeHost;
|
||||
_logger = logger;
|
||||
_officeProcessWatcher = officeProcessWatcher;
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_collector = new DeliveryOptimizationCollector(
|
||||
@@ -47,7 +52,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
|
||||
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
|
||||
},
|
||||
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory));
|
||||
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
onOfficeDoSample: onOfficeDoSample);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -70,10 +76,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
};
|
||||
_imeHost.Tracker.OnAppStateChanged = _chainedHandler;
|
||||
|
||||
// Second wake source: an Office C2R install (no IME package) keeps the collector polling
|
||||
// so it can capture Office's DO CDN jobs.
|
||||
if (_officeProcessWatcher != null)
|
||||
{
|
||||
_officeProcessWatcher.Started += OnOfficeStarted;
|
||||
_officeProcessWatcher.Stopped += OnOfficeStopped;
|
||||
}
|
||||
|
||||
_collector.Start();
|
||||
_logger.Info($"DeliveryOptimizationHost: started dormant (interval={_collector}, wakes on Downloading/Installing).");
|
||||
_logger.Info("DeliveryOptimizationHost: started dormant (wakes on Downloading/Installing or an Office C2R install).");
|
||||
}
|
||||
|
||||
private void OnOfficeStarted(object? sender, EventArgs e) => _collector.NotifyOfficeActive(true);
|
||||
private void OnOfficeStopped(object? sender, EventArgs e) => _collector.NotifyOfficeActive(false);
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
// Restore the previous handler only if we're still the current one — otherwise
|
||||
@@ -86,6 +103,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
_imeHost.Tracker.OnAppStateChanged = _prevStateChanged;
|
||||
}
|
||||
if (_officeProcessWatcher != null)
|
||||
{
|
||||
try { _officeProcessWatcher.Started -= OnOfficeStarted; } catch { }
|
||||
try { _officeProcessWatcher.Stopped -= OnOfficeStopped; } catch { }
|
||||
}
|
||||
_collector.Stop();
|
||||
}
|
||||
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Lifecycle host for the event-driven <see cref="OfficeInstallDetector"/> (Rev 2). Owns the OS
|
||||
/// watchers and orchestrates the pure detector core — no idle polling:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="OfficeProcessWatcher"/> Started → arm a fresh <see cref="RegistryChangeWatcher"/>
|
||||
/// on the ClickToRun key + <c>OnWorkerStarted</c>.</item>
|
||||
/// <item>RegistryChangeWatcher Changed → <c>OnRegistryChanged</c> (push, no poll).</item>
|
||||
/// <item>OfficeProcessWatcher Stopped → <c>OnWorkerStopped</c> (terminal) + dispose the reg watcher.</item>
|
||||
/// </list>
|
||||
/// The <see cref="OfficeProcessWatcher"/> is exposed so the DeliveryOptimizationHost can subscribe
|
||||
/// to the same start/stop signal (to wake DO sampling for Office), and <see cref="SubmitDoSample"/>
|
||||
/// lets the DO collector fold aggregated Office DO stats into the office_install_* events.
|
||||
/// </summary>
|
||||
internal sealed class OfficeInstallDetectorHost : ICollectorHost
|
||||
{
|
||||
private const string ClickToRunSubKey = @"SOFTWARE\Microsoft\Office\ClickToRun";
|
||||
|
||||
public string Name => OfficeInstallDetector.SourceName;
|
||||
|
||||
private readonly OfficeInstallDetector _detector;
|
||||
private readonly OfficeProcessWatcher _processWatcher;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly object _lock = new object();
|
||||
private RegistryChangeWatcher? _registryWatcher;
|
||||
private int _disposed;
|
||||
|
||||
public OfficeInstallDetectorHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
int settleSeconds)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock);
|
||||
_processWatcher = new OfficeProcessWatcher(logger, settleSeconds);
|
||||
_processWatcher.Started += OnWorkerStarted;
|
||||
_processWatcher.Stopped += OnWorkerStopped;
|
||||
}
|
||||
|
||||
/// <summary>The shared Office worker start/stop signal — the DeliveryOptimizationHost subscribes.</summary>
|
||||
public OfficeProcessWatcher ProcessWatcher => _processWatcher;
|
||||
|
||||
/// <summary>Fold aggregated Office DO stats (from the DO collector) into the office_install_* events.</summary>
|
||||
public void SubmitDoSample(OfficeDoSample sample) => _detector.OnOfficeDoSample(sample);
|
||||
|
||||
public void Start() => _processWatcher.Start();
|
||||
|
||||
private void OnWorkerStarted(object? sender, EventArgs e)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Fresh registry watcher per install window (RegNotify re-arms internally per fire).
|
||||
_registryWatcher?.Dispose();
|
||||
_registryWatcher = new RegistryChangeWatcher(ClickToRunSubKey, _logger);
|
||||
_registryWatcher.Changed += OnRegistryChanged;
|
||||
_registryWatcher.Start();
|
||||
}
|
||||
_detector.OnWorkerStarted();
|
||||
}
|
||||
|
||||
private void OnRegistryChanged(object? sender, EventArgs e) => _detector.OnRegistryChanged();
|
||||
|
||||
private void OnWorkerStopped(object? sender, EventArgs e)
|
||||
{
|
||||
_detector.OnWorkerStopped();
|
||||
lock (_lock)
|
||||
{
|
||||
if (_registryWatcher != null)
|
||||
{
|
||||
try { _registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
_registryWatcher.Dispose();
|
||||
_registryWatcher = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop() => Dispose();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _processWatcher.Started -= OnWorkerStarted; } catch { }
|
||||
try { _processWatcher.Stopped -= OnWorkerStopped; } catch { }
|
||||
try { _processWatcher.Dispose(); } catch { }
|
||||
lock (_lock)
|
||||
{
|
||||
try { _registryWatcher?.Dispose(); } catch { }
|
||||
_registryWatcher = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 28, // V2 DAD-liveness threshold (DesktopDetectorNoCandidateTimeoutMinutes, default 10 min); admin-config global setting
|
||||
ConfigVersion = 29, // V2 OfficeInstallDetector (EnableOfficeInstallDetector + OfficeInstallDetectorIntervalSeconds; C2R install lifecycle via registry+process)
|
||||
UploadIntervalSeconds = 10,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
|
||||
@@ -63,6 +63,10 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'do_telemetry',
|
||||
'all_apps_completed',
|
||||
'app_tracking_summary',
|
||||
'office_install_started',
|
||||
'office_install_progress',
|
||||
'office_install_completed',
|
||||
'office_install_failed',
|
||||
],
|
||||
script_events: ['script_started', 'script_completed', 'script_failed'],
|
||||
network_events: [
|
||||
|
||||
@@ -296,6 +296,17 @@ namespace AutopilotMonitor.Shared
|
||||
public const string AllAppsCompleted = "all_apps_completed";
|
||||
public const string AppTrackingSummary = "app_tracking_summary"; // Plan §5 Fix 4b — terminal per-session app summary
|
||||
|
||||
// Microsoft 365 Apps (Office Click-to-Run) install lifecycle — emitted by the V2
|
||||
// OfficeInstallDetector which watches the C2R client (registry + process) directly.
|
||||
// The Intune "integrated" Microsoft 365 Apps app reports done to IME within 1-2 min
|
||||
// while C2R keeps streaming/installing in the background; these events surface the
|
||||
// REAL install progress (phase-granular — no percentage is exposed by Office) plus
|
||||
// products, channel, version reached, duration and a best-effort failure code.
|
||||
public const string OfficeInstallStarted = "office_install_started";
|
||||
public const string OfficeInstallProgress = "office_install_progress";
|
||||
public const string OfficeInstallCompleted = "office_install_completed";
|
||||
public const string OfficeInstallFailed = "office_install_failed";
|
||||
|
||||
// Stall detection (Ebene 2 — StallProbeCollector)
|
||||
public const string StallProbeCheck = "stall_probe_check"; // Trace heartbeat from Probe 2 (15 min) when no anomaly found
|
||||
public const string StallProbeResult = "stall_probe_result"; // Warning when a probe found an anomaly
|
||||
|
||||
@@ -284,6 +284,26 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public int DeliveryOptimizationIntervalSeconds { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Enable the Microsoft 365 Apps (Office Click-to-Run) install detector. Event-driven (no idle
|
||||
/// polling): woken by a WMI Win32_ProcessStartTrace push on OfficeC2RClient.exe, progress via
|
||||
/// RegNotifyChangeKeyValue, stop via Process.Exited. Surfaces the real background install
|
||||
/// lifecycle (+ Office's Delivery-Optimization download telemetry) even when the Intune
|
||||
/// "integrated" Office app reports done to IME within 1-2 min. High-value package → on by
|
||||
/// default, kept as a kill-switch. DO sampling reuses DeliveryOptimizationIntervalSeconds.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool EnableOfficeInstallDetector { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Settle window (seconds) for the Office install detector. C2R spawns several short-lived
|
||||
/// OfficeC2RClient.exe workers (with small gaps) during one install; after the last worker
|
||||
/// exits the detector waits this long before deciding terminal completed/failed, so a brief
|
||||
/// gap before the next worker does not produce a premature office_install_failed. A new worker
|
||||
/// within the window keeps the install window open. 0 = decide immediately. Default: 10.
|
||||
/// </summary>
|
||||
public int OfficeInstallSettleSeconds { get; set; } = 10;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stall detection (Ebene 2 — StallProbeCollector)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -84,6 +84,14 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
|
||||
description: "Download progress update (throttled)." },
|
||||
{ value: "do_telemetry", label: "do_telemetry", category: "app",
|
||||
description: "Delivery Optimization peer/mode telemetry for an application download." },
|
||||
{ value: "office_install_started", label: "office_install_started", category: "app",
|
||||
description: "Microsoft 365 Apps (Office Click-to-Run) background install detected by the C2R detector." },
|
||||
{ value: "office_install_progress", label: "office_install_progress", category: "app",
|
||||
description: "Office C2R install phase changed (Streaming/Finalizing) — emitted only on real state change, not periodically." },
|
||||
{ value: "office_install_completed", label: "office_install_completed", category: "app",
|
||||
description: "Office C2R install completed (streaming finished, scenario cleared); carries version reached and duration." },
|
||||
{ value: "office_install_failed", label: "office_install_failed", category: "app",
|
||||
description: "Office C2R install ended without reaching completion, or a failure code was observed." },
|
||||
|
||||
// -------- Scripts --------
|
||||
{ value: "script_started", label: "script_started", category: "script",
|
||||
|
||||
Reference in New Issue
Block a user