mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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]
|
||||
|
||||
+1
-4
@@ -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,
|
||||
|
||||
+27
-2
@@ -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
|
||||
{
|
||||
|
||||
+10
-1
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user