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