Ops events next to the four direct Telegram pings (push-relay Phase 0)

TenantSignup, SessionReportSubmitted, DiagFilesReportSubmitted and RuleSubmissionReceived
(category Tenant, Info) are recorded where the backend pings the Telegram chat directly, so an
alert rule can route them to any channel — the Push channel included. Like FeedbackReceived,
message and details carry ids and a portal path only, never the submitter or a comment (the
user-id column keeps the submitter for the admin view and never reaches a channel). The ops
event is awaited before the response; the Telegram pings stay untouched until they are retired.
Dual-registered (constants, All, web rule list), vocabularies regenerated, health marker row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-08 16:56:16 +02:00
parent f5117e2290
commit 24ea5c76c8
11 changed files with 94 additions and 6 deletions
@@ -32,6 +32,7 @@ public class AuthFunctionSideEffectTests
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
private readonly Mock<TelegramNotificationService> _telegramMock;
private readonly Mock<OpsEventService> _opsEventsMock;
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
private readonly Mock<IMetricsRepository> _metricsRepoMock;
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
@@ -96,6 +97,9 @@ public class AuthFunctionSideEffectTests
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
_signalR = new FakeSignalRNotificationService();
_opsEventsMock = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
_opsEventsMock.Setup(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
_sut = new AuthFunction(
Mock.Of<ILogger<AuthFunction>>(),
globalAdminMock.Object,
@@ -115,7 +119,8 @@ public class AuthFunctionSideEffectTests
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
_signalR,
adminConfigService.Object,
_offboardingRepo);
_offboardingRepo,
opsEvents: _opsEventsMock.Object);
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
// a brand-new tenant). Default: all fire-and-forget calls succeed.
@@ -234,6 +239,7 @@ public class AuthFunctionSideEffectTests
Assert.Equal(CurrentDpa, written.DpaVersion);
Assert.Equal("auth", source);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record next to the ping
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
"preview_signup", "New Tenant Signup",
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
@@ -39,6 +39,7 @@ public class AuthFunction
private readonly AdminConfigurationService _adminConfigService;
private readonly IOffboardingAuditRepository _offboardingRepo;
private readonly IPushDeviceRepository? _pushRepo;
private readonly OpsEventService? _opsEvents;
public AuthFunction(
ILogger<AuthFunction> logger,
@@ -57,10 +58,12 @@ public class AuthFunction
ISignalRNotificationService signalRService,
AdminConfigurationService adminConfigService,
IOffboardingAuditRepository offboardingRepo,
IPushDeviceRepository? pushRepo = null)
IPushDeviceRepository? pushRepo = null,
OpsEventService? opsEvents = null)
{
_logger = logger;
_pushRepo = pushRepo;
_opsEvents = opsEvents;
_offboardingRepo = offboardingRepo;
_adminConfigService = adminConfigService;
_identityResolver = identityResolver;
@@ -470,6 +473,11 @@ public class AuthFunction
// This login's view follows what was just stored.
Seed(tenantConfig);
// The ops event is the routable record (alert rules → any channel, Push included); it is
// written before the response and never throws. The Telegram ping below stays until retired.
if (_opsEvents != null)
await _opsEvents.RecordTenantSignupAsync(tenantId, upn);
// Fire-and-forget: Telegram
_ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
@@ -24,19 +24,22 @@ namespace AutopilotMonitor.Functions.Functions.Reports
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly GlobalNotificationService _globalNotificationService;
private readonly OpsEventService? _opsEvents;
public SubmitDiagFilesReportFunction(
ILogger<SubmitDiagFilesReportFunction> logger,
SessionReportService sessionReportService,
IMaintenanceRepository maintenanceRepo,
TelegramNotificationService telegramNotificationService,
GlobalNotificationService globalNotificationService)
GlobalNotificationService globalNotificationService,
OpsEventService? opsEvents = null)
{
_logger = logger;
_sessionReportService = sessionReportService;
_maintenanceRepo = maintenanceRepo;
_telegramNotificationService = telegramNotificationService;
_globalNotificationService = globalNotificationService;
_opsEvents = opsEvents;
}
[Function("SubmitDiagFilesReport")]
@@ -110,6 +113,10 @@ namespace AutopilotMonitor.Functions.Functions.Reports
);
}
// Ops event (routable through alert rules, Push included) — written before the response, never throws
if (_opsEvents != null)
await _opsEvents.RecordDiagFilesReportSubmittedAsync(request.TenantId, userIdentifier, metadata.ReportId);
// Telegram notification — best effort
_ = _telegramNotificationService.SendDiagFilesReportAsync(
request.TenantId, userIdentifier, metadata.ReportId, request.Comment ?? string.Empty);
@@ -18,19 +18,22 @@ namespace AutopilotMonitor.Functions.Functions.Reports
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly GlobalNotificationService _globalNotificationService;
private readonly OpsEventService? _opsEvents;
public SubmitSessionReportFunction(
ILogger<SubmitSessionReportFunction> logger,
SessionReportService sessionReportService,
IMaintenanceRepository maintenanceRepo,
TelegramNotificationService telegramNotificationService,
GlobalNotificationService globalNotificationService)
GlobalNotificationService globalNotificationService,
OpsEventService? opsEvents = null)
{
_logger = logger;
_sessionReportService = sessionReportService;
_maintenanceRepo = maintenanceRepo;
_telegramNotificationService = telegramNotificationService;
_globalNotificationService = globalNotificationService;
_opsEvents = opsEvents;
}
[Function("SubmitSessionReport")]
@@ -119,6 +122,10 @@ namespace AutopilotMonitor.Functions.Functions.Reports
);
}
// Ops event (routable through alert rules, Push included) — written before the response, never throws
if (_opsEvents != null)
await _opsEvents.RecordSessionReportSubmittedAsync(request.TenantId, userIdentifier, sessionId, metadata.ReportId);
// Telegram notification — best effort
_ = _telegramNotificationService.SendSessionReportAsync(
request.TenantId, userIdentifier, sessionId, metadata.ReportId, request.Comment ?? string.Empty);
@@ -20,19 +20,22 @@ namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly TelegramNotificationService _telegram;
private readonly GlobalNotificationService _globalNotifications;
private readonly OpsEventService? _opsEvents;
public SubmitRuleSubmissionsFunction(
ILogger<SubmitRuleSubmissionsFunction> logger,
RuleSubmissionService service,
IMaintenanceRepository maintenanceRepo,
TelegramNotificationService telegram,
GlobalNotificationService globalNotifications)
GlobalNotificationService globalNotifications,
OpsEventService? opsEvents = null)
{
_logger = logger;
_service = service;
_maintenanceRepo = maintenanceRepo;
_telegram = telegram;
_globalNotifications = globalNotifications;
_opsEvents = opsEvents;
}
[Function("SubmitRuleSubmissions")]
@@ -108,6 +111,9 @@ namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
// Operator notifications — best effort, one per rule so every id has its own deep link.
foreach (var s in created)
{
// Ops event (routable through alert rules, Push included) — written before the response, never throws
if (_opsEvents != null)
await _opsEvents.RecordRuleSubmissionReceivedAsync(request.TenantId, userIdentifier, s.SubmissionId, s.RuleKind, s.SourceRuleId);
_ = _telegram.SendRuleSubmissionAsync(s.SubmissionId, request.TenantId, userIdentifier, s.RuleKind, s.SourceRuleId, s.Title, s.Comment ?? string.Empty);
_ = _globalNotifications.CreateNotificationAsync(
"rule_submission",
@@ -1181,6 +1181,32 @@ namespace AutopilotMonitor.Functions.Services
null, "System.Push",
new { host, scope = scopeKind });
// ── Tenant: customer-initiated submissions (plan push-relay, Phase 0) ─────
// Info events next to the direct Telegram pings, so an alert rule can route them to any
// channel. Like FeedbackReceived, message and details carry ids only — never the submitter
// or a comment; the portal page behind portalPath has them. The user id column keeps the
// submitter for the admin view and never reaches a channel.
public virtual Task RecordTenantSignupAsync(string tenantId, string upn)
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.TenantSignup, OpsEventSeverity.Info,
"New tenant signed up", tenantId, upn,
new { portalPath = "/admin/tenants" });
public virtual Task RecordSessionReportSubmittedAsync(string tenantId, string submittedBy, string sessionId, string reportId)
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.SessionReportSubmitted, OpsEventSeverity.Info,
"Session report submitted", tenantId, submittedBy,
new { sessionId, reportId, portalPath = $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(reportId)}" });
public virtual Task RecordDiagFilesReportSubmittedAsync(string tenantId, string submittedBy, string reportId)
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.DiagFilesReportSubmitted, OpsEventSeverity.Info,
"Diagnostic files submitted", tenantId, submittedBy,
new { reportId, portalPath = $"/admin/reports/session-reports?reportId={Uri.EscapeDataString(reportId)}" });
public virtual Task RecordRuleSubmissionReceivedAsync(string tenantId, string submittedBy, string submissionId, string ruleKind, string ruleId)
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.RuleSubmissionReceived, OpsEventSeverity.Info,
"Rule submission received", tenantId, submittedBy,
new { submissionId, ruleKind, ruleId, portalPath = $"/admin/reports/rule-submissions?submissionId={Uri.EscapeDataString(submissionId)}" });
// ── Core write method ──────────────────────────────────────────────────
private async Task WriteAsync(string category, string eventType, string severity,
@@ -106,6 +106,10 @@ export const OPS_EVENT_TYPES = [
"McpServicePrincipalFirstSeen",
"OffboardingFeedbackReceived",
"FeedbackReceived",
"TenantSignup",
"SessionReportSubmitted",
"DiagFilesReportSubmitted",
"RuleSubmissionReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
"TenantAutoApproved",
@@ -94,6 +94,18 @@ namespace AutopilotMonitor.Shared.DataAccess
/// the portal (User Feedback), so a channel bound to this rule receives no personal data.
/// </summary>
public const string FeedbackReceived = "FeedbackReceived";
/// <summary>
/// A new tenant signed up: its first sign-in seeded the domain (<c>auth/me</c>). Info. Recorded
/// next to the direct Telegram ping until that is retired (plan push-relay, Phase 0); message and
/// details carry no personal data — the Tenants page has the rest.
/// </summary>
public const string TenantSignup = "TenantSignup";
/// <summary>A member submitted a session report. Info; details name the session and report id only.</summary>
public const string SessionReportSubmitted = "SessionReportSubmitted";
/// <summary>A member submitted logs without a session (Submit Logs). Info; details name the report id only.</summary>
public const string DiagFilesReportSubmitted = "DiagFilesReportSubmitted";
/// <summary>A member submitted a gather or analyze rule for review. Info; details name submission, kind and source rule id only.</summary>
public const string RuleSubmissionReceived = "RuleSubmissionReceived";
public const string TenantOffboarded = "TenantOffboarded";
public const string TenantOffboardingFailed = "TenantOffboardingFailed";
public const string TenantAutoApproved = "TenantAutoApproved";
@@ -168,7 +180,7 @@ namespace AutopilotMonitor.Shared.DataAccess
ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles,
MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, MaintenanceStarted, MaintenanceSkippedLocked, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift,
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, PrivilegedRouteDenied, McpServicePrincipalFirstSeen,
OffboardingFeedbackReceived, FeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
OffboardingFeedbackReceived, FeedbackReceived, TenantSignup, SessionReportSubmitted, DiagFilesReportSubmitted, RuleSubmissionReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable, TelemetryItemsRejected,
SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted,
AzureMonitorAlert, PushDeliveryFailed, PushEndpointRefused,
@@ -157,6 +157,10 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// FeedbackReceived — a member sent feedback from the help menu. Info-tier; carries no text
// and no sender, the feedback itself is read under Reports → User Feedback.
"FeedbackReceived",
"TenantSignup",
"SessionReportSubmitted",
"DiagFilesReportSubmitted",
"RuleSubmissionReceived",
// TenantTrialStarted — the conversion moment, fired by PlanManagementFunction from BOTH
// plan write paths (self-service POST trial and a GA grant via PATCH plan). Info-tier;
// the payload carries domain, contact address and who granted it, so a sales/support
@@ -633,6 +633,10 @@ export const SHARED_MANIFEST = {
"McpServicePrincipalFirstSeen",
"OffboardingFeedbackReceived",
"FeedbackReceived",
"TenantSignup",
"SessionReportSubmitted",
"DiagFilesReportSubmitted",
"RuleSubmissionReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
"TenantAutoApproved",
@@ -629,6 +629,10 @@
"McpServicePrincipalFirstSeen",
"OffboardingFeedbackReceived",
"FeedbackReceived",
"TenantSignup",
"SessionReportSubmitted",
"DiagFilesReportSubmitted",
"RuleSubmissionReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
"TenantAutoApproved",