using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Integration; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.AuditLog.Site.Telemetry; /// /// Audit Log #23 (M3 Bundle E — Tasks E4/E5): translates per-attempt /// notifications from the store-and-forward retry loop into one (or two) /// packets and pushes them through /// . /// /// /// /// The S&F loop's reports a /// single coarse outcome per attempt; the audit pipeline however models the /// lifecycle as TWO rows on terminal outcomes — an Attempted /// ( / ) /// row capturing the per-attempt mechanics, plus a /// row marking the terminal state for downstream consumers. The bridge fans /// out per outcome: /// /// /// TransientFailure -> one Attempted(Failed) row. /// Delivered -> Attempted(Delivered) + CachedResolve(Delivered). /// PermanentFailure -> Attempted(Failed) + CachedResolve(Parked). /// ParkedMaxRetries -> Attempted(Failed) + CachedResolve(Parked). /// /// /// Best-effort emission (alog.md §7): the bridge itself never throws; /// the underlying forwarder swallows + logs its own failures. /// /// public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver { private readonly ICachedCallTelemetryForwarder _forwarder; private readonly ILogger _logger; public CachedCallLifecycleBridge( ICachedCallTelemetryForwarder forwarder, ILogger logger) { _forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task OnAttemptCompletedAsync( CachedCallAttemptContext context, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(context); try { await EmitAttemptedAsync(context, ct).ConfigureAwait(false); if (IsTerminal(context.Outcome)) { await EmitResolveAsync(context, ct).ConfigureAwait(false); } } catch (Exception ex) { // Defensive — both EmitX paths call the forwarder which is itself // best-effort. A throw here is unexpected, but the alog.md §7 // contract requires we never propagate. _logger.LogWarning(ex, "CachedCallLifecycleBridge: unexpected throw for {TrackedOperationId} (Outcome {Outcome})", context.TrackedOperationId, context.Outcome); } } private async Task EmitAttemptedAsync(CachedCallAttemptContext context, CancellationToken ct) { // Per-attempt row: kind discriminates channel; status is always // Attempted regardless of outcome (success vs. failure is captured // by the companion HttpStatus / ErrorMessage fields, NOT by flipping // the status — CachedResolve carries the terminal Status). Per the // M3 brief and alog.md §4. var kind = ChannelToAttemptKind(context.Channel); var status = AuditStatus.Attempted; var packet = BuildPacket( context, kind: kind, status: status, // Operational status mirror — for the per-attempt row the // operational state is the running status; the bridge always // writes "Attempted" so reconciliation can't roll back. operationalStatus: "Attempted", terminalAtUtc: null, lastError: context.LastError, httpStatus: context.HttpStatus); await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false); } private async Task EmitResolveAsync(CachedCallAttemptContext context, CancellationToken ct) { var (auditStatus, operationalStatus) = TerminalOutcomeToStatuses(context.Outcome); var packet = BuildPacket( context, kind: AuditKind.CachedResolve, status: auditStatus, operationalStatus: operationalStatus, terminalAtUtc: context.OccurredAtUtc, lastError: context.LastError, httpStatus: context.HttpStatus); await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false); } private static CachedCallTelemetry BuildPacket( CachedCallAttemptContext context, AuditKind kind, AuditStatus status, string operationalStatus, DateTime? terminalAtUtc, string? lastError, int? httpStatus) { var channel = ChannelStringToEnum(context.Channel); return new CachedCallTelemetry( Audit: new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc), Channel = channel, Kind = kind, CorrelationId = context.TrackedOperationId.Value, SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite, SourceInstanceId = context.SourceInstanceId, SourceScript = null, // Not threaded through S&F; left null on retry-loop rows. Target = context.Target, Status = status, HttpStatus = httpStatus, DurationMs = context.DurationMs, ErrorMessage = lastError, ForwardState = AuditForwardState.Pending, }, Operational: new SiteCallOperational( TrackedOperationId: context.TrackedOperationId, Channel: context.Channel, Target: context.Target, SourceSite: context.SourceSite, Status: operationalStatus, RetryCount: context.RetryCount, LastError: lastError, HttpStatus: httpStatus, CreatedAtUtc: DateTime.SpecifyKind(context.CreatedAtUtc, DateTimeKind.Utc), UpdatedAtUtc: DateTime.SpecifyKind(context.OccurredAtUtc, DateTimeKind.Utc), TerminalAtUtc: terminalAtUtc is null ? null : DateTime.SpecifyKind(terminalAtUtc.Value, DateTimeKind.Utc))); } private static AuditKind ChannelToAttemptKind(string channel) => channel switch { "ApiOutbound" => AuditKind.ApiCallCached, "DbOutbound" => AuditKind.DbWriteCached, // Defensive default — the S&F observer is filtered to cached-call // categories so this branch shouldn't fire in practice. _ => AuditKind.ApiCallCached, }; private static AuditChannel ChannelStringToEnum(string channel) => channel switch { "ApiOutbound" => AuditChannel.ApiOutbound, "DbOutbound" => AuditChannel.DbOutbound, _ => AuditChannel.ApiOutbound, }; private static (AuditStatus auditStatus, string operationalStatus) TerminalOutcomeToStatuses( CachedCallAttemptOutcome outcome) => outcome switch { CachedCallAttemptOutcome.Delivered => (AuditStatus.Delivered, "Delivered"), CachedCallAttemptOutcome.PermanentFailure => (AuditStatus.Parked, "Parked"), CachedCallAttemptOutcome.ParkedMaxRetries => (AuditStatus.Parked, "Parked"), // TransientFailure isn't terminal — see IsTerminal — but the switch // is exhaustive so we route it through Failed for safety. CachedCallAttemptOutcome.TransientFailure => (AuditStatus.Failed, "Failed"), _ => (AuditStatus.Failed, "Failed"), }; private static bool IsTerminal(CachedCallAttemptOutcome outcome) => outcome switch { CachedCallAttemptOutcome.Delivered => true, CachedCallAttemptOutcome.PermanentFailure => true, CachedCallAttemptOutcome.ParkedMaxRetries => true, CachedCallAttemptOutcome.TransientFailure => false, _ => false, }; }