using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Configuration;
using ScadaLink.AuditLog.Payload;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.AuditLog;
///
/// Composition root for the Audit Log (#23) component.
///
///
///
/// M1 registered + the validator. M2 Bundle E
/// extends the surface with the site-side writer chain
/// ( + +
/// ) and the telemetry collaborators
/// (, ,
/// , ,
/// ).
///
///
/// Audit Log (#23) sits alongside Notification Outbox (#21) and Site Call
/// Audit (#22). IAuditLogRepository is registered by
/// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase,
/// so the caller (the Host on the central node) must also call that.
///
///
public static class ServiceCollectionExtensions
{
/// Configuration section bound to .
public const string ConfigSectionName = "AuditLog";
/// Configuration section bound to .
public const string SiteWriterSectionName = "AuditLog:SiteWriter";
/// Configuration section bound to .
public const string SiteTelemetrySectionName = "AuditLog:SiteTelemetry";
/// Configuration section bound to .
public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance";
///
/// Registers the Audit Log (#23) component services: options, the site
/// SQLite writer chain (primary + ring fallback + failure-counter sink),
/// and the site-→central telemetry collaborators. Idempotent re-registration
/// is not supported; call this exactly once per .
///
public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
// M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.).
services.AddOptions()
.Bind(config.GetSection(ConfigSectionName))
.ValidateOnStart();
services.AddSingleton, AuditLogOptionsValidator>();
// M5 Bundle A: payload filter — truncates oversized RequestSummary /
// ResponseSummary / ErrorDetail / Extra fields between event
// construction and persistence. Bundle B layers header / body /
// SQL-parameter redaction onto the same singleton; Bundle C wires it
// into the FallbackAuditWriter / CentralAuditWriter / IngestActor
// paths. Singleton — the filter is stateless and the IOptionsMonitor
// dependency picks up M5-T8 hot reloads on its own.
services.AddSingleton();
// M5 Bundle B: per-stage redactor-failure counter. NoOp default;
// Bundle C replaces this binding with the Site Health Monitoring
// bridge that surfaces failures as AuditRedactionFailure on the site
// health report.
services.TryAddSingleton();
// M2 Bundle E: site writer + telemetry options bindings.
// BindConfiguration is not used because the configuration root supplied
// by the caller may not be the application root — we go through the
// section explicitly so a partial IConfiguration (e.g. a test stub
// anchored on the AuditLog section's parent) still works.
services.AddOptions()
.Bind(config.GetSection(SiteWriterSectionName));
services.AddOptions()
.Bind(config.GetSection(SiteTelemetrySectionName));
// SqliteAuditWriter is a singleton with a single owned SqliteConnection
// and a background writer Task; multiple instances would race on the
// same file. Registered concretely so the ISiteAuditQueue + IAuditWriter
// forwards below resolve to the same instance — the actor must observe
// the writes made via the hot-path interface.
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
// RingBufferFallback: drop-oldest in-memory ring used by
// FallbackAuditWriter when the primary SQLite writer throws. Default
// capacity is fine for M2 (1024).
services.AddSingleton();
// IAuditWriteFailureCounter: NoOp default. Bundle G overrides this
// binding with the real Site Health Monitoring counter. Registered
// before FallbackAuditWriter so the factory can resolve it.
services.AddSingleton();
// The script-thread surface is FallbackAuditWriter (primary + ring +
// counter), not the raw SqliteAuditWriter — primary failures must NEVER
// abort the user-facing action.
// Bundle C (M5-T6): the IAuditPayloadFilter singleton above is wired
// through the factory so every event written through this surface is
// truncated + redacted before it hits SQLite (and the ring on
// failure).
services.AddSingleton(sp => new FallbackAuditWriter(
primary: sp.GetRequiredService(),
ring: sp.GetRequiredService(),
failureCounter: sp.GetRequiredService(),
logger: sp.GetRequiredService>(),
filter: sp.GetRequiredService()));
// ISiteStreamAuditClient: NoOp default. This binding remains correct for
// central/test composition roots that have no SiteCommunicationActor.
// The real implementation is ClusterClientSiteAuditClient, which pushes
// audit telemetry to central over Akka ClusterClient via the site's
// SiteCommunicationActor — the Host wires it directly into the
// SiteAuditTelemetryActor's Props.Create call for site roles (it cannot
// be a DI singleton because it needs the SiteCommunicationActor IActorRef,
// created during Akka bootstrap, not at DI-composition time).
services.AddSingleton();
// M3 Bundle F: site-side dual emitter for cached-call lifecycle
// telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall /
// Database.CachedWrite resolves this through DI and pushes one combined
// packet per lifecycle event; the forwarder writes the audit half
// through IAuditWriter and the operational half through the
// IOperationTrackingStore. The audit writer is always wired (the M2
// chain above); the operational tracking store is SITE-ONLY (registered
// by ScadaLink.SiteRuntime). On a Central composition root the tracking
// store has no registration, so the factory resolves it with GetService
// (returning null) — the forwarder degrades to "audit-only" emission,
// mirroring the lazy IAuditWriter chain established in M2.
services.AddSingleton(sp =>
new CachedCallTelemetryForwarder(
sp.GetRequiredService(),
sp.GetService(),
sp.GetRequiredService>()));
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook
// to the cached-call forwarder so per-attempt + terminal telemetry
// emitted from the S&F retry sweep lands on the same SQLite hot-path
// as the script-thread CachedSubmit row. Registered as a singleton
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
// can resolve it through DI (Bundle F StoreAndForward wiring change).
services.AddSingleton();
services.AddSingleton(
sp => sp.GetRequiredService());
// M6 Bundle E (T8): central audit-write failure counter — NoOp default
// for site/test composition roots that don't wire the central health
// snapshot. AddAuditLogCentralMaintenance below replaces this binding
// with the AuditCentralHealthSnapshot implementation so increments
// surface on the central dashboard.
services.TryAddSingleton();
// M4 Bundle B: central direct-write audit writer used by
// NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to
// emit AuditLog rows that originate ON central, not via site telemetry.
// Singleton — the writer is stateless; its per-call scope opens a fresh
// IAuditLogRepository (a SCOPED EF Core service registered by
// ScadaLink.ConfigurationDatabase). The interface (ICentralAuditWriter)
// is intentionally distinct from IAuditWriter so site composition roots
// do not accidentally bind it; central composition roots that include
// AddConfigurationDatabase get a working implementation transparently.
// Bundle C (M5-T6): wire the IAuditPayloadFilter into the factory so
// NotificationOutboxActor + Inbound API rows are truncated + redacted
// before they hit MS SQL.
// M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter
// so swallowed repo throws bump the central health counter.
services.AddSingleton(sp => new CentralAuditWriter(
sp,
sp.GetRequiredService>(),
sp.GetRequiredService(),
sp.GetRequiredService()));
return services;
}
///
/// Audit Log (#23) M2 Bundle G + M5 Bundle C — swap the default
/// and
/// registrations for the
/// real /
/// bridges so the
/// FallbackAuditWriter primary-failure counter AND the
/// DefaultAuditPayloadFilter redactor-failure counter both surface in the
/// site health report payload as
/// SiteHealthReport.SiteAuditWriteFailures +
/// SiteHealthReport.AuditRedactionFailure.
///
///
///
/// Must be called AFTER both (registers the
/// NoOp defaults this method replaces) and
/// ScadaLink.HealthMonitoring.ServiceCollectionExtensions.AddHealthMonitoring
/// or AddSiteHealthMonitoring (registers the
/// the bridges depend on). Resolving
/// or
/// without the latter throws
/// at GetRequiredService
/// time — by design, since a silent NoOp would mask a misconfiguration.
///
///
/// Idempotent — calling twice replaces each descriptor without piling up
/// registrations.
///
///
/// Site-side only for M5: the central composition root keeps the NoOp
/// defaults; the central health-metric surface that would expose
/// AuditRedactionFailure next to the existing central counters
/// ships in M6.
///
///
public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.Replace(
ServiceDescriptor.Singleton());
services.Replace(
ServiceDescriptor.Singleton());
// M6 Bundle E (T6): the site-side backlog reporter polls the
// SqliteAuditWriter every 30 s and pushes the snapshot into the
// collector so the next SiteHealthReport carries a fresh
// SiteAuditBacklog field. Registered alongside the other site-only
// metric bridges so AddAuditLog (which runs on central too) stays
// free of hosted-service registrations that would resolve a missing
// ISiteHealthCollector on central.
services.AddHostedService();
return services;
}
///
/// Audit Log (#23) M6-T5 Bundle D — central-only registration for the
/// hosted service plus
/// its binding. Must be
/// called from the Central role's composition root (not from a site
/// composition root); the underlying IPartitionMaintenance
/// implementation is registered by AddConfigurationDatabase and
/// only exists on the central node.
///
///
///
/// Separated from because AddAuditLog is
/// also invoked from site composition roots — silently starting a
/// hosted service that resolves an unregistered dependency on a site
/// would fail every tick. Keeping the central-only registration in its
/// own helper preserves the "every Add* call is safe to issue
/// from any composition root" invariant.
///
///
public static IServiceCollection AddAuditLogCentralMaintenance(
this IServiceCollection services,
IConfiguration config)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
services.AddOptions()
.Bind(config.GetSection(PartitionMaintenanceSectionName));
services.AddHostedService();
// M6 Bundle E (T8 + T9): central health snapshot — a single object
// that owns the CentralAuditWriteFailures + AuditRedactionFailure
// Interlocked counters AND surfaces them on
// IAuditCentralHealthSnapshot. The same instance is bound to BOTH
// writer-side interfaces (ICentralAuditWriteFailureCounter +
// IAuditRedactionFailureCounter) so every central-side increment
// routes into the shared counters; site nodes keep their existing
// Site bridges (registered by AddAuditLogHealthMetricsBridge) so
// the same counter type does not shadow the site-side metric.
// The snapshot itself has no actor-system dependency — the
// per-site stalled latch is fed by SiteAuditTelemetryStalledTracker
// which the Akka bootstrap wires up after ActorSystem.Create returns
// (the tracker is NOT registered here because its construction
// requires ActorSystem, which is not a DI-resolvable singleton).
services.AddSingleton();
services.AddSingleton(
sp => sp.GetRequiredService());
services.Replace(ServiceDescriptor.Singleton(
sp => sp.GetRequiredService()));
// M6 Bundle E (T9): override the NoOp IAuditRedactionFailureCounter
// (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter
// bridge so payload-filter throws on CentralAuditWriter /
// AuditLogIngestActor paths surface on the central dashboard. The
// bridge is a thin wrapper around the AuditCentralHealthSnapshot
// singleton so all central redactor failures route into the same
// counter as CentralAuditWriteFailures. The site composition root
// overrides this binding AGAIN via AddAuditLogHealthMetricsBridge —
// central nodes do not call that bridge, so this is the final
// binding on a central host. Mirrors the M5 Bundle C
// HealthMetricsAuditRedactionFailureCounter shape one-for-one.
services.Replace(ServiceDescriptor.Singleton());
return services;
}
}