From d3261fb890f4cdbd496dec70fd5a763e9b3be16b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 29 Aug 2026 19:30:20 +0200 Subject: [PATCH] feat(ops): ops events for Collect Logs usage and diagnostics-upload enablement CollectLogsQuickConfigEnabled fires when a tenant admin switches on Hosted diagnostics upload through the session Collect Logs quick-config dialog (PUT ?intent=collect-logs -> write source portal-collect-logs); every other enable/disable path emits DiagnosticsUploadEnabled/Disabled (PUT, PATCH, revert, MCP). SessionActionQueued records portal-queued server actions. DiagnosticsUploadConfigChange is the single definition behind the diagnosticsUploadConfigured feature flag and the flip detection. Event types dual-registered in the OpsAlertRules catalog. Co-Authored-By: Claude Fable 5 --- .../CollectLogsQuickConfigOpsEventTests.cs | 97 +++++++++++++++++++ .../DiagnosticsUploadConfigChangeTests.cs | 73 ++++++++++++++ .../TenantConfigPatchServiceTests.cs | 56 ++++++++++- .../Config/GetTenantFeatureFlagsFunction.cs | 5 +- .../UpdateTenantConfigurationFunction.cs | 29 +++++- .../Sessions/QueueSessionActionFunction.cs | 11 ++- .../Services/DiagnosticsUploadConfigChange.cs | 60 ++++++++++++ .../Services/OpsEventService.cs | 44 +++++++++ .../Services/TenantConfigPatchService.cs | 12 +++ .../admin/components/OpsAlertRulesSection.tsx | 16 +++ .../sessions/components/CollectLogsButton.tsx | 2 +- src/Web/autopilot-monitor-web/lib/api.ts | 4 + 12 files changed, 400 insertions(+), 9 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/CollectLogsQuickConfigOpsEventTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/DiagnosticsUploadConfigChangeTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/DiagnosticsUploadConfigChange.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CollectLogsQuickConfigOpsEventTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CollectLogsQuickConfigOpsEventTests.cs new file mode 100644 index 00000000..a3f2247b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CollectLogsQuickConfigOpsEventTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The targeted push signal: "someone clicked Collect Logs and switched on Hosted upload +/// through the quick-config dialog" must land as its own ops event type, while every other +/// enable path keeps the generic type — an alert rule filters by EventType only. +/// +public class CollectLogsQuickConfigOpsEventTests +{ + private static (OpsEventService Service, List Saved) Rig() + { + var saved = new List(); + var opsRepo = new Mock(); + opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) + .Callback(e => { lock (saved) saved.Add(e); }) + .Returns(Task.CompletedTask); + var adminConfig = new Mock( + Mock.Of(), NullLogger.Instance, + new MemoryCache(new MemoryCacheOptions())) { CallBase = false }; + var alertDispatch = new OpsAlertDispatchService( + adminConfig.Object, + new TelegramNotificationService(new HttpClient(), Mock.Of(), + NullLogger.Instance), + new WebhookNotificationService(new HttpClient(), NullLogger.Instance), + NullLogger.Instance); + return (new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch), saved); + } + + private static DiagnosticsUploadConfigChange EnableFlip() => + DiagnosticsUploadConfigChange.Detect( + new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off" }, + new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "OnFailure", DiagnosticsUploadDestination = "Hosted" })!; + + [Theory] + [InlineData("collect-logs", UpdateTenantConfigurationFunction.CollectLogsSource)] + [InlineData("COLLECT-LOGS", UpdateTenantConfigurationFunction.CollectLogsSource)] + [InlineData(null, "portal-put")] + [InlineData("", "portal-put")] + [InlineData("something-else", "portal-put")] + public void ResolveWriteSource_only_honours_the_allow_listed_intent(string? intent, string expected) + { + Assert.Equal(expected, UpdateTenantConfigurationFunction.ResolveWriteSource(intent)); + } + + [Fact] + public async Task Quick_config_enable_lands_as_its_own_event_type() + { + var (service, saved) = Rig(); + + await service.RecordDiagnosticsUploadConfigChangedAsync( + "t1", "contoso.com", EnableFlip(), "admin@contoso.com", UpdateTenantConfigurationFunction.CollectLogsSource); + + var evt = Assert.Single(saved); + Assert.Equal("CollectLogsQuickConfigEnabled", evt.EventType); + Assert.Equal(OpsEventCategory.Tenant, evt.Category); + Assert.Contains("admin@contoso.com", evt.Message); + Assert.Contains("Hosted", evt.Message); + } + + [Theory] + [InlineData("portal-put")] + [InlineData("mcp-patch")] + public async Task Other_enable_paths_keep_the_generic_event_type(string source) + { + var (service, saved) = Rig(); + + await service.RecordDiagnosticsUploadConfigChangedAsync("t1", "contoso.com", EnableFlip(), "admin@contoso.com", source); + + Assert.Equal("DiagnosticsUploadEnabled", Assert.Single(saved).EventType); + } + + [Fact] + public async Task Quick_config_source_on_a_disable_is_the_generic_disabled_type() + { + var (service, saved) = Rig(); + var disable = DiagnosticsUploadConfigChange.Detect( + new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Always", DiagnosticsUploadDestination = "Hosted" }, + new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off", DiagnosticsUploadDestination = "Hosted" })!; + + await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", UpdateTenantConfigurationFunction.CollectLogsSource); + + Assert.Equal("DiagnosticsUploadDisabled", Assert.Single(saved).EventType); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosticsUploadConfigChangeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosticsUploadConfigChangeTests.cs new file mode 100644 index 00000000..4f249ea6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosticsUploadConfigChangeTests.cs @@ -0,0 +1,73 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The Collect Logs capability flip that feeds the DiagnosticsUploadEnabled/Disabled ops +/// events: only a change of "would an on-demand upload work" counts, never a plain edit. +/// +public class DiagnosticsUploadConfigChangeTests +{ + private static TenantConfiguration Cfg(string? mode, string? destination = null, string? sas = null) => new() + { + TenantId = "t1", + DiagnosticsUploadMode = mode!, + DiagnosticsUploadDestination = destination!, + DiagnosticsBlobSasUrl = sas!, + }; + + [Theory] + [InlineData(null, null, null, false)] + [InlineData("Off", "Hosted", null, false)] + [InlineData("OnFailure", null, null, false)] + [InlineData("OnFailure", "Hosted", null, true)] + [InlineData("Always", "CustomerSas", "https://x.blob.core.windows.net/c?sig=1", true)] + [InlineData("Always", null, "https://x.blob.core.windows.net/c?sig=1", true)] + public void IsConfigured_mirrors_the_feature_flag_semantics(string? mode, string? dest, string? sas, bool expected) + { + Assert.Equal(expected, DiagnosticsUploadConfigChange.IsConfigured(Cfg(mode, dest, sas))); + Assert.False(DiagnosticsUploadConfigChange.IsConfigured(null)); + } + + [Fact] + public void Detect_quick_config_flip_reports_enabled_with_destination_and_mode() + { + // The Collect Logs quick-config dialog: Off/unset -> Hosted + OnFailure. + var change = DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure", "Hosted")); + + Assert.NotNull(change); + Assert.True(change!.Enabled); + Assert.Equal("Hosted", change.Destination); + Assert.Equal("OnFailure", change.Mode); + } + + [Fact] + public void Detect_missing_before_row_counts_as_not_configured() + { + var change = DiagnosticsUploadConfigChange.Detect(null, Cfg("Always", "Hosted")); + Assert.True(change?.Enabled); + } + + [Fact] + public void Detect_reports_disabled_when_mode_goes_off() + { + var change = DiagnosticsUploadConfigChange.Detect(Cfg("Always", "Hosted"), Cfg("Off", "Hosted")); + + Assert.NotNull(change); + Assert.False(change!.Enabled); + Assert.Equal("Off", change.Mode); + } + + [Fact] + public void Detect_ignores_edits_that_keep_the_capability_state() + { + // Mode change while staying on: no flip. + Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("OnFailure", "Hosted"), Cfg("Always", "Hosted"))); + // Destination not yet usable in both states: no flip. + Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure"))); + // Unchanged. + Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("Off"))); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs index de5bca5d..1ac8f5f2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigPatchServiceTests.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Functions.DataAccess.TableStorage; using AutopilotMonitor.Functions.Functions.Admin; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; @@ -35,6 +37,8 @@ public class TenantConfigPatchServiceTests public Mock Repo { get; } = new(); public Mock Backup { get; } = new(); public Mock Maintenance { get; } = new(); + public Mock OpsRepo { get; } = new(); + public List OpsEvents { get; } = new(); public TenantConfigPatchService Sut { get; } public TenantConfiguration? Current; @@ -89,8 +93,23 @@ public class TenantConfigPatchServiceTests Repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + OpsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) + .Callback(e => { lock (OpsEvents) OpsEvents.Add(e); }) + .Returns(Task.CompletedTask); + var adminConfig = new Mock( + Mock.Of(), NullLogger.Instance, + new MemoryCache(new MemoryCacheOptions())) { CallBase = false }; + var alertDispatch = new OpsAlertDispatchService( + adminConfig.Object, + new TelegramNotificationService(new HttpClient(), Mock.Of(), + NullLogger.Instance), + new WebhookNotificationService(new HttpClient(), + NullLogger.Instance), + NullLogger.Instance); + var opsService = new OpsEventService(OpsRepo.Object, NullLogger.Instance, alertDispatch); + Sut = new TenantConfigPatchService( - Repo.Object, Backup.Object, configService, Maintenance.Object, + Repo.Object, Backup.Object, configService, Maintenance.Object, opsService, NullLogger.Instance); } @@ -116,6 +135,41 @@ public class TenantConfigPatchServiceTests return o; } + // ── Ops visibility: Collect Logs capability flip ──────────────────────── + + [Fact] + public async Task Patch_EnablingDiagnosticsUpload_EmitsOpsEvent_OnlyOnTheFlip() + { + var stored = Stored(); + stored.DiagnosticsUploadMode = "Off"; + var harness = new Harness(stored); + + // Settings page / MCP: turn the capability on (mode + Hosted destination). + var on = await harness.Sut.ApplyFieldPatchAsync( + TenantId, Fields(("diagnosticsUploadMode", "OnFailure"), ("diagnosticsUploadDestination", "Hosted")), + Ga, "mcp-patch", "enable collect logs"); + Assert.True(on.Success, on.Error); + + var enabled = Assert.Single(harness.OpsEvents); + Assert.Equal("DiagnosticsUploadEnabled", enabled.EventType); + Assert.Equal(TenantId, enabled.TenantId); + Assert.Contains("contoso.com", enabled.Message); + Assert.Contains("mcp-patch", enabled.Message); + + // A further edit that keeps it on is not a flip. + var tweak = await harness.Sut.ApplyFieldPatchAsync( + TenantId, Fields(("diagnosticsUploadMode", "Always")), Ga, "mcp-patch", null); + Assert.True(tweak.Success, tweak.Error); + Assert.Single(harness.OpsEvents); + + // Turning it off is the opposite flip. + var off = await harness.Sut.ApplyFieldPatchAsync( + TenantId, Fields(("diagnosticsUploadMode", "Off")), Ga, "mcp-patch", null); + Assert.True(off.Success, off.Error); + Assert.Equal(2, harness.OpsEvents.Count); + Assert.Equal("DiagnosticsUploadDisabled", harness.OpsEvents[1].EventType); + } + // ── Happy path ────────────────────────────────────────────────────────── [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs index 5e65be9f..4b9d673b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs @@ -86,10 +86,7 @@ namespace AutopilotMonitor.Functions.Functions.Config // succeed right now (mode not Off + a usable destination). Members below Admin cannot // read the full config, so this boolean is their only signal. Deliberately exposes no // destination detail — just "would an upload work". - diagnosticsUploadConfigured = - !string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase) - && GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled( - config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination), + diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config), // Drives the "Autopilot Device Validation disabled" dashboard banner // (useTenantSecurityConfig). validateAutopilotDevice = config.ValidateAutopilotDevice, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index d8f421eb..7849f557 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -21,17 +21,32 @@ namespace AutopilotMonitor.Functions.Functions.Config private readonly ILogger _logger; private readonly TenantConfigurationService _configService; private readonly IMaintenanceRepository _maintenanceRepo; + private readonly OpsEventService _opsEventService; public UpdateTenantConfigurationFunction( ILogger logger, TenantConfigurationService configService, - IMaintenanceRepository maintenanceRepo) + IMaintenanceRepository maintenanceRepo, + OpsEventService opsEventService) { _logger = logger; _configService = configService; _maintenanceRepo = maintenanceRepo; + _opsEventService = opsEventService; } + /// + /// Write-path label for the session-detail Collect Logs quick-config dialog. The dialog + /// sends ?intent=collect-logs so the backup snapshot and the ops event can name + /// the path — a plain Settings/Admin PUT is portal-put. Allow-listed, never echoed + /// from arbitrary input. + /// + internal const string CollectLogsIntent = "collect-logs"; + internal const string CollectLogsSource = "portal-collect-logs"; + + internal static string ResolveWriteSource(string? intent) => + string.Equals(intent, CollectLogsIntent, StringComparison.OrdinalIgnoreCase) ? CollectLogsSource : "portal-put"; + [Function("UpdateTenantConfiguration")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "config/{tenantId}")] HttpRequestData req, @@ -183,7 +198,8 @@ namespace AutopilotMonitor.Functions.Functions.Config config.TrialGrantedBy = existingConfig.TrialGrantedBy; // Save configuration (retention cap already enforced by ValidateModel above) - await _configService.SaveConfigurationAsync(config, "portal-put", null); + var writeSource = ResolveWriteSource(req.Query["intent"]); + await _configService.SaveConfigurationAsync(config, writeSource, null); var changes = ConfigDiffHelper.GetChanges(existingConfig, config); await _maintenanceRepo.LogAuditEntryAsync( @@ -195,6 +211,15 @@ namespace AutopilotMonitor.Functions.Functions.Config changes.Count > 0 ? changes : null ); + // Operator visibility for the Collect Logs capability flip (the quick-config + // dialog writes through this PUT). OpsEventService never throws. + var diagChange = DiagnosticsUploadConfigChange.Detect(existingConfig, config); + if (diagChange != null) + { + await _opsEventService.RecordDiagnosticsUploadConfigChangedAsync( + requestCtx.TargetTenantId, config.DomainName, diagChange, userIdentifier ?? "unknown", writeSource); + } + var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs index f0702482..4d8e469a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs @@ -4,6 +4,7 @@ using System.IO; using System.Net; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.ApplicationInsights; @@ -24,6 +25,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions private readonly ILogger _logger; private readonly ISessionRepository _sessionRepo; private readonly TelemetryClient _telemetryClient; + private readonly OpsEventService _opsEventService; private static readonly HashSet AllowedTypes = new(StringComparer.OrdinalIgnoreCase) { @@ -57,11 +59,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public QueueSessionActionFunction( ILogger logger, ISessionRepository sessionRepo, - TelemetryClient telemetryClient) + TelemetryClient telemetryClient, + OpsEventService opsEventService) { _logger = logger; _sessionRepo = sessionRepo; _telemetryClient = telemetryClient; + _opsEventService = opsEventService; } /// @@ -131,6 +135,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions return notFound; } + // Ops feed: who uses the on-demand session controls (Collect Logs, quick-config, + // terminate). App Insights alone is invisible to alert rules. Never throws. + await _opsEventService.RecordSessionActionQueuedAsync( + tenantId, sessionId, action.Type, action.Reason, userIdentifier ?? "unknown"); + _telemetryClient.TrackEvent("ServerActionQueued", new Dictionary { { "tenantId", tenantId }, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DiagnosticsUploadConfigChange.cs b/src/Backend/AutopilotMonitor.Functions/Services/DiagnosticsUploadConfigChange.cs new file mode 100644 index 00000000..44d54aa1 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/DiagnosticsUploadConfigChange.cs @@ -0,0 +1,60 @@ +using System; +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// "Can an on-demand diagnostics upload succeed for this tenant?" — the single definition + /// behind the session-detail Collect Logs button (feature flag + /// diagnosticsUploadConfigured) and the ops-event tripwire that reports when a + /// tenant admin turns that capability on or off (portal Settings, the Collect Logs + /// quick-config dialog, MCP update_tenant_config, or a config revert). + /// + public sealed class DiagnosticsUploadConfigChange + { + public bool Enabled { get; } + public string? Destination { get; } + public string Mode { get; } + + private DiagnosticsUploadConfigChange(bool enabled, string? destination, string mode) + { + Enabled = enabled; + Destination = destination; + Mode = mode; + } + + /// + /// Mode not Off AND a usable destination (customer SAS or Hosted). Mirrors the agent's + /// own gate (DiagnosticsUploadMode + DiagnosticsUploadEnabled), so + /// "configured" here means the agent would actually build and upload a package. + /// + public static bool IsConfigured(TenantConfiguration? config) + { + if (config == null) return false; + return !string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase) + && GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled( + config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination); + } + + /// + /// Returns the flip when changes the configured state relative + /// to ; null when the capability did not change (including + /// destination/mode edits that keep it on). A missing row + /// counts as "not configured". + /// + public static DiagnosticsUploadConfigChange? Detect(TenantConfiguration? before, TenantConfiguration after) + { + if (after == null) throw new ArgumentNullException(nameof(after)); + + var wasConfigured = IsConfigured(before); + var isConfigured = IsConfigured(after); + if (wasConfigured == isConfigured) return null; + + return new DiagnosticsUploadConfigChange( + isConfigured, + after.DiagnosticsUploadDestination, + after.DiagnosticsUploadMode ?? "Off"); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index e42b01f3..4eb11619 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -717,8 +717,52 @@ namespace AutopilotMonitor.Functions.Services lift, }); + /// + /// A tenant admin turned on-demand diagnostics upload on or off — the capability behind + /// the session-detail Collect Logs button (portal Settings, the Collect Logs quick-config + /// dialog, MCP update_tenant_config, or a config revert). Fires only on the flip, not on + /// every diagnostics edit (). + /// Info-tier. The Collect Logs quick-config dialog (source portal-collect-logs) + /// gets its own type CollectLogsQuickConfigEnabled so a rule can push on exactly + /// "someone clicked Collect Logs and switched on Hosted upload" without also firing on + /// Settings-page edits; every other enable path is DiagnosticsUploadEnabled. + /// Dual-register per memory feedback_ops_event_types_dual_register. + /// + public Task RecordDiagnosticsUploadConfigChangedAsync( + string tenantId, string? domainName, DiagnosticsUploadConfigChange change, string changedBy, string source) + { + var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})"; + var quickConfig = string.Equals(source, Functions.Config.UpdateTenantConfigurationFunction.CollectLogsSource, StringComparison.Ordinal); + if (change.Enabled && quickConfig) + { + return WriteAsync(OpsEventCategory.Tenant, "CollectLogsQuickConfigEnabled", OpsEventSeverity.Info, + $"Collect Logs: {changedBy} enabled diagnostics upload for tenant {tenantLabel} via the session quick-config dialog (destination {change.Destination ?? "?"}, mode {change.Mode})", + tenantId, changedBy, new { domainName, change.Destination, change.Mode, source }); + } + + return change.Enabled + ? WriteAsync(OpsEventCategory.Tenant, "DiagnosticsUploadEnabled", OpsEventSeverity.Info, + $"Diagnostics upload enabled for tenant {tenantLabel} (destination {change.Destination ?? "?"}, mode {change.Mode}) via {source} by {changedBy}", + tenantId, changedBy, new { domainName, change.Destination, change.Mode, source }) + : WriteAsync(OpsEventCategory.Tenant, "DiagnosticsUploadDisabled", OpsEventSeverity.Info, + $"Diagnostics upload disabled for tenant {tenantLabel} (mode {change.Mode}) via {source} by {changedBy}", + tenantId, changedBy, new { domainName, change.Destination, change.Mode, source }); + } + // ── Agent ────────────────────────────────────────────────────────────── + /// + /// A portal user queued a ServerAction for a live session (Collect Logs = + /// request_diagnostics, quick-config = rotate_config, admin + /// terminate_session). Rule-engine-queued actions do NOT pass through here — + /// this is the "who uses the on-demand controls, and how often" signal. + /// Dual-register per memory feedback_ops_event_types_dual_register. + /// + public Task RecordSessionActionQueuedAsync(string tenantId, string sessionId, string actionType, string? reason, string queuedBy) + => WriteAsync(OpsEventCategory.Agent, "SessionActionQueued", OpsEventSeverity.Info, + $"Server action '{actionType}' queued for session {sessionId} by {queuedBy}: {reason}", + tenantId, queuedBy, new { sessionId, actionType, reason }); + public Task RecordSessionTimeoutsAsync(string tenantId, int sessionCount, int timeoutHours) => WriteAsync(OpsEventCategory.Agent, "SessionTimeouts", OpsEventSeverity.Info, $"{sessionCount} session(s) timed out after {timeoutHours}h", diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index 41d939be..9e299967 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Services private readonly IConfigBackupRepository _backupRepo; private readonly TenantConfigurationService _configService; private readonly IMaintenanceRepository _maintenanceRepo; + private readonly OpsEventService _opsEventService; private readonly ILogger _logger; public TenantConfigPatchService( @@ -99,12 +100,14 @@ namespace AutopilotMonitor.Functions.Services IConfigBackupRepository backupRepo, TenantConfigurationService configService, IMaintenanceRepository maintenanceRepo, + OpsEventService opsEventService, ILogger logger) { _configRepo = configRepo; _backupRepo = backupRepo; _configService = configService; _maintenanceRepo = maintenanceRepo; + _opsEventService = opsEventService; _logger = logger; } @@ -421,6 +424,15 @@ namespace AutopilotMonitor.Functions.Services await _maintenanceRepo.LogAuditEntryAsync( tenantId, auditAction, "TenantConfiguration", tenantId, updatedBy, auditDetails); + // Operator visibility for the Collect Logs capability flip — the verified re-read is + // the truth of what landed, so detect on it rather than on the candidate. + var diagChange = DiagnosticsUploadConfigChange.Detect(initial, reread.Value.Config); + if (diagChange != null) + { + await _opsEventService.RecordDiagnosticsUploadConfigChangedAsync( + tenantId, reread.Value.Config.DomainName, diagChange, updatedBy, source); + } + var applied = expected.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList(); return new PatchOutcome(true, PatchFailure.None, null, backupId, applied, maskedDiff, null); } diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index 260984e0..61868ba6 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -156,6 +156,17 @@ const OPS_EVENT_TYPES: Record = { "WelcomeEmailSent", "WelcomeEmailSkipped", "WelcomeEmailFailed", + // A tenant admin turned on-demand diagnostics upload (the Collect Logs capability) on or + // off — portal Settings, the Collect Logs quick-config dialog, MCP update_tenant_config, + // or a config revert. Fires on the flip only. Backend + // RecordDiagnosticsUploadConfigChangedAsync. Dual-register per memory + // feedback_ops_event_types_dual_register. + "DiagnosticsUploadEnabled", + "DiagnosticsUploadDisabled", + // Someone clicked Collect Logs on a session and switched on Hosted diagnostics upload + // through the quick-config dialog (PUT ?intent=collect-logs). Replaces + // DiagnosticsUploadEnabled for that path only — the targeted push signal. + "CollectLogsQuickConfigEnabled", ], Agent: [ "BlobStorageMissing", @@ -178,6 +189,11 @@ const OPS_EVENT_TYPES: Record = { // EventIngestProcessor (RecordCmTraceTimeSkewRegressionAsync). Dual-register per memory // feedback_ops_event_types_dual_register. "CmTraceTimeSkewRegression", + // A portal user queued a server action for a live session — Collect Logs + // (request_diagnostics), quick-config (rotate_config) or terminate_session. Emitted by + // QueueSessionActionFunction (RecordSessionActionQueuedAsync); rule-engine actions are + // not included. Dual-register per memory feedback_ops_event_types_dual_register. + "SessionActionQueued", ], Platform: [ // Azure Monitor alerts relayed through POST ops/alert-webhook (Common Alert Schema → diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx index 015a4ef0..178995e1 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/CollectLogsButton.tsx @@ -145,7 +145,7 @@ export default function CollectLogsButton({ : config.diagnosticsUploadMode, }; - const putRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken, { + const putRes = await authenticatedFetch(api.config.tenantCollectLogsQuickConfig(effectiveTenantId), getAccessToken, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updated), diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 67f3b759..c4613cd8 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -132,6 +132,10 @@ export const api = { config: { all: () => `${API_BASE_URL}/api/config/all`, tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`, + // Same PUT, labelled: the session-detail Collect Logs quick-config dialog. The backend + // maps the intent to write source "portal-collect-logs" and emits the dedicated + // CollectLogsQuickConfigEnabled ops event instead of the generic DiagnosticsUploadEnabled. + tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}?intent=collect-logs`, /** * PATCH — transactional field-level config write (TenantAdminOrGA, own row): * { fields: { : value | null, ... }, reason?: string }. The backend