Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs
T
Oliver Kieselbach 9f7b070e80 chore: remove legacy V1 agent and its backend ingest path
The V1 agent line is retired: no new V1 sessions since 2026-05-09, only
orphaned straggler traffic remained on /api/agent/ingest (last seen
2026-06-29, events for long-cleaned-up sessions). Bootstrap/Intune/CI
have deployed V2-only since the v2 cutover.

Agent:
- Delete AutopilotMonitor.Agent, .Agent.Core, .Agent.Core.Tests
  (projects + sln entries). V2 trio, SummaryDialog, DecisionCore.Tests
  and V2Isolation.targets stay.

Backend:
- Relocate the shared statics/DTOs V2 reused from the V1 partials
  (StampServerFields, SanitizeEventTimestamps, IsMaxLifetimeAgentShutdown,
  RecalculateAppDurations, EventClassification, AppInstallAggregationState)
  into EventIngestProcessor; IngestEventsOutput moves next to
  IngestTelemetryFunction (same namespace, zero ripple).
- Delete IngestEventsFunction (route agent/ingest), BootstrapIngestEvents
  (route bootstrap/ingest), NdjsonParser and their DI registration.
  EventDataNormalizer stays (used by TelemetryPayloadParser).
- Drop both routes from EndpointAccessPolicyCatalog and
  RequestTelemetryMiddleware.TenantHeaderTrustedPaths.
- Retire the V1 update-hash oracle: LatestAgentV1*/LatestBootstrapV1*
  fields + GetAgentLine case 1 (default arm returns empty hashes, so
  stragglers gracefully skip the integrity check; ParseAgentMajor keeps
  its default of 1 on purpose — defaulting to 2 would feed V2 hashes to
  V1 binaries). TableConfigRepository Store+Map updated in lockstep.
- Remove ApiEndpoints.IngestEvents/.BootstrapIngestEvents constants.

Tests: NdjsonParserTests deleted; timestamp/critical-path/max-lifetime
tests repointed to EventIngestProcessor; the two NDJSON pipeline tests
removed (coverage retained by direct-list siblings); catalog/no-store
InlineData rows updated.

Docs: architecture.md (endpoint tables, ingest flow diagram, solution
layout, test matrix), web SectionAgentInternals endpoint tables,
build.ps1 help text.

Azure: no config change needed — /api/agent is not in the client-cert
exclusion paths (agent/ingest was always cert-enforced) and the
/api/bootstrap prefix exclusion remains for the surviving bootstrap
routes (verified via ARM on autopilotmonitor-api).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:53:20 +02:00

168 lines
7.7 KiB
C#

using System.Linq;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// External + real-time notification plumbing: outgoing webhook alerts (Teams/Slack-style)
/// and SignalR messages for the live UI push.
/// </summary>
public sealed partial class EventIngestProcessor
{
private async Task SendWebhookNotificationsAsync(
IngestEventsRequest request, string sessionPrefix, EventClassification c,
SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason,
List<RuleResult> ruleResults)
{
var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId);
var (webhookUrl, providerTypeInt) = tenantConfig.GetEffectiveWebhookConfig();
if (string.IsNullOrEmpty(webhookUrl) || providerTypeInt == 0)
return;
var providerType = (WebhookProviderType)providerTypeInt;
var customHeaders = tenantConfig.GetGenericWebhookHeaders();
var sessionUrl = updatedSession != null
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
: null;
if (statusTransitioned && (c.CompletionEvent != null || c.FailureEvent != null))
{
var notifySuccess = c.CompletionEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess();
var notifyFailure = c.FailureEvent != null && tenantConfig.GetEffectiveNotifyOnFailure();
if (notifySuccess || notifyFailure)
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null)
{
var completionTime = c.CompletionEvent?.Timestamp ?? c.FailureEvent?.Timestamp;
if (completionTime.HasValue)
duration = completionTime.Value - updatedSession.ResumedAt.Value;
}
var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: c.CompletionEvent != null,
failureReason: failureReason,
duration: duration,
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
}
if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess())
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
var alert = NotificationAlertBuilder.BuildWhiteGloveAlert(
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: true,
duration: duration,
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
if (c.EspFailureEvent != null && updatedSession?.IsPreProvisioned == true && tenantConfig.GetEffectiveNotifyOnFailure())
{
var duration = updatedSession?.DurationSeconds != null
? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value)
: (TimeSpan?)null;
var alert = NotificationAlertBuilder.BuildWhiteGloveAlert(
updatedSession?.DeviceName,
updatedSession?.SerialNumber,
updatedSession?.Manufacturer,
updatedSession?.Model,
success: false,
duration: duration,
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
_ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
}
private SignalRMessageAction[] BuildSignalRMessages(
IngestEventsRequest request, SessionSummary? updatedSession, int processedCount,
List<RuleResult> newRuleResults)
{
object? sessionDelta = updatedSession != null ? new {
updatedSession.CurrentPhase,
updatedSession.CurrentPhaseDetail,
updatedSession.Status,
updatedSession.FailureReason,
updatedSession.EventCount,
updatedSession.DurationSeconds,
updatedSession.CompletedAt,
updatedSession.DiagnosticsBlobName,
updatedSession.IsPreProvisioned
} : null;
var summaryMessage = new SignalRMessageAction("newevents")
{
GroupName = $"tenant-{request.TenantId}",
Arguments = new object[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
eventCount = processedCount,
sessionUpdate = sessionDelta
} }
};
var slimRuleResults = newRuleResults.Count > 0
? newRuleResults.Select(r => new {
r.ResultId,
r.RuleId,
r.RuleTitle,
r.Severity,
r.Category,
r.ConfidenceScore,
r.Explanation,
r.Remediation,
r.RelatedDocs,
r.MatchedConditions,
r.DetectedAt
}).ToList<object>()
: null;
var eventsMessage = new SignalRMessageAction("eventStream")
{
GroupName = $"session-{request.TenantId}-{request.SessionId}",
Arguments = new object[] { new {
sessionId = request.SessionId,
tenantId = request.TenantId,
newEventCount = processedCount,
newRuleResults = slimRuleResults
} }
};
return new[] { summaryMessage, eventsMessage };
}
}
}