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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-29 19:30:20 +02:00
parent b3bbcf1300
commit d3261fb890
12 changed files with 400 additions and 9 deletions
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class CollectLogsQuickConfigOpsEventTests
{
private static (OpsEventService Service, List<OpsEventEntry> Saved) Rig()
{
var saved = new List<OpsEventEntry>();
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (saved) saved.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.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);
}
}
@@ -0,0 +1,73 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
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")));
}
}
@@ -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<IConfigRepository> Repo { get; } = new();
public Mock<IConfigBackupRepository> Backup { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public Mock<IOpsEventRepository> OpsRepo { get; } = new();
public List<OpsEventEntry> OpsEvents { get; } = new();
public TenantConfigPatchService Sut { get; }
public TenantConfiguration? Current;
@@ -89,8 +93,23 @@ public class TenantConfigPatchServiceTests
Repo.Object, NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
OpsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (OpsEvents) OpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var opsService = new OpsEventService(OpsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new TenantConfigPatchService(
Repo.Object, Backup.Object, configService, Maintenance.Object,
Repo.Object, Backup.Object, configService, Maintenance.Object, opsService,
NullLogger<TenantConfigPatchService>.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]
@@ -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,
@@ -21,17 +21,32 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly ILogger<UpdateTenantConfigurationFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEventService;
public UpdateTenantConfigurationFunction(
ILogger<UpdateTenantConfigurationFunction> logger,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService)
{
_logger = logger;
_configService = configService;
_maintenanceRepo = maintenanceRepo;
_opsEventService = opsEventService;
}
/// <summary>
/// Write-path label for the session-detail Collect Logs quick-config dialog. The dialog
/// sends <c>?intent=collect-logs</c> so the backup snapshot and the ops event can name
/// the path — a plain Settings/Admin PUT is <c>portal-put</c>. Allow-listed, never echoed
/// from arbitrary input.
/// </summary>
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<HttpResponseData> 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
{
@@ -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<QueueSessionActionFunction> _logger;
private readonly ISessionRepository _sessionRepo;
private readonly TelemetryClient _telemetryClient;
private readonly OpsEventService _opsEventService;
private static readonly HashSet<string> AllowedTypes = new(StringComparer.OrdinalIgnoreCase)
{
@@ -57,11 +59,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
public QueueSessionActionFunction(
ILogger<QueueSessionActionFunction> logger,
ISessionRepository sessionRepo,
TelemetryClient telemetryClient)
TelemetryClient telemetryClient,
OpsEventService opsEventService)
{
_logger = logger;
_sessionRepo = sessionRepo;
_telemetryClient = telemetryClient;
_opsEventService = opsEventService;
}
/// <summary>
@@ -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<string, string>
{
{ "tenantId", tenantId },
@@ -0,0 +1,60 @@
using System;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// "Can an on-demand diagnostics upload succeed for this tenant?" — the single definition
/// behind the session-detail Collect Logs button (feature flag
/// <c>diagnosticsUploadConfigured</c>) 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 <c>update_tenant_config</c>, or a config revert).
/// </summary>
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;
}
/// <summary>
/// Mode not Off AND a usable destination (customer SAS or Hosted). Mirrors the agent's
/// own gate (<c>DiagnosticsUploadMode</c> + <c>DiagnosticsUploadEnabled</c>), so
/// "configured" here means the agent would actually build and upload a package.
/// </summary>
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);
}
/// <summary>
/// Returns the flip when <paramref name="after"/> changes the configured state relative
/// to <paramref name="before"/>; null when the capability did not change (including
/// destination/mode edits that keep it on). A missing <paramref name="before"/> row
/// counts as "not configured".
/// </summary>
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");
}
}
}
@@ -717,8 +717,52 @@ namespace AutopilotMonitor.Functions.Services
lift,
});
/// <summary>
/// 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 (<see cref="DiagnosticsUploadConfigChange.Detect"/>).
/// Info-tier. The Collect Logs quick-config dialog (source <c>portal-collect-logs</c>)
/// gets its own type <c>CollectLogsQuickConfigEnabled</c> 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 <c>DiagnosticsUploadEnabled</c>.
/// Dual-register per memory feedback_ops_event_types_dual_register.
/// </summary>
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 ──────────────────────────────────────────────────────────────
/// <summary>
/// A portal user queued a ServerAction for a live session (Collect Logs =
/// <c>request_diagnostics</c>, quick-config = <c>rotate_config</c>, admin
/// <c>terminate_session</c>). 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.
/// </summary>
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",
@@ -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<TenantConfigPatchService> _logger;
public TenantConfigPatchService(
@@ -99,12 +100,14 @@ namespace AutopilotMonitor.Functions.Services
IConfigBackupRepository backupRepo,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService,
ILogger<TenantConfigPatchService> 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);
}
@@ -156,6 +156,17 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
"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<string, string[]> = {
// 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 →
@@ -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),
+4
View File
@@ -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: { <wireFieldName>: value | null, ... }, reason?: string }. The backend