// ----------------------------------------------------------------------------- // ScanMetricsCollector.cs // Sprint: SPRINT_3406_0001_0001_metrics_tables // Task: METRICS-3406-008 // Description: Service for collecting and persisting scan metrics during execution // ----------------------------------------------------------------------------- using System.Diagnostics; using Microsoft.Extensions.Logging; using StellaOps.Scanner.Storage.Models; using StellaOps.Scanner.Storage.Repositories; namespace StellaOps.Scanner.Worker.Metrics; /// /// Collects and persists scan metrics during execution. /// Thread-safe for concurrent phase tracking. /// public sealed class ScanMetricsCollector : IDisposable { private readonly IScanMetricsRepository _repository; private readonly ILogger _logger; private readonly Guid _scanId; private readonly Guid _tenantId; private readonly string _artifactDigest; private readonly string _artifactType; private readonly string _scannerVersion; private readonly Stopwatch _totalStopwatch = new(); private readonly object _lock = new(); private readonly Dictionary _phases = new(); private readonly List _completedPhases = []; private DateTimeOffset _startedAt; private Guid _metricsId; private bool _disposed; // Result tracking private string? _findingsSha256; private string? _vexBundleSha256; private string? _proofBundleSha256; private string? _sbomSha256; private string? _policyDigest; private string? _feedSnapshotId; private int? _packageCount; private int? _findingCount; private int? _vexDecisionCount; private Guid? _surfaceId; private string? _replayManifestHash; private string? _scannerImageDigest; private bool _isReplay; public ScanMetricsCollector( IScanMetricsRepository repository, ILogger logger, Guid scanId, Guid tenantId, string artifactDigest, string artifactType, string scannerVersion) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _scanId = scanId; _tenantId = tenantId; _artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest)); _artifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType)); _scannerVersion = scannerVersion ?? throw new ArgumentNullException(nameof(scannerVersion)); _metricsId = Guid.NewGuid(); } /// /// Gets the metrics ID for this scan. /// public Guid MetricsId => _metricsId; /// /// Start collecting metrics. /// public void Start() { _startedAt = DateTimeOffset.UtcNow; _totalStopwatch.Start(); _logger.LogDebug("Started metrics collection for scan {ScanId}", _scanId); } /// /// Start tracking a phase. /// public IDisposable StartPhase(string phaseName) { lock (_lock) { if (_phases.ContainsKey(phaseName)) { _logger.LogWarning("Phase {PhaseName} already started for scan {ScanId}", phaseName, _scanId); return NoOpDisposable.Instance; } var tracker = new PhaseTracker(this, phaseName, DateTimeOffset.UtcNow); _phases[phaseName] = tracker; _logger.LogDebug("Started phase {PhaseName} for scan {ScanId}", phaseName, _scanId); return tracker; } } /// /// Complete a phase with success. /// public void CompletePhase(string phaseName, Dictionary? metrics = null) { CompletePhaseInternal(phaseName, success: true, errorCode: null, errorMessage: null, metrics); } /// /// Complete a phase with failure. /// public void FailPhase(string phaseName, string errorCode, string? errorMessage = null) { CompletePhaseInternal(phaseName, success: false, errorCode, errorMessage, metrics: null); } private void CompletePhaseInternal( string phaseName, bool success, string? errorCode, string? errorMessage, IReadOnlyDictionary? metrics) { lock (_lock) { if (!_phases.TryGetValue(phaseName, out var tracker)) { _logger.LogWarning("Phase {PhaseName} not started for scan {ScanId}", phaseName, _scanId); return; } _phases.Remove(phaseName); var finishedAt = DateTimeOffset.UtcNow; var phase = new ExecutionPhase { MetricsId = _metricsId, PhaseName = phaseName, PhaseOrder = ScanPhaseNames.GetPhaseOrder(phaseName), StartedAt = tracker.StartedAt, FinishedAt = finishedAt, Success = success, ErrorCode = errorCode, ErrorMessage = errorMessage, PhaseMetrics = metrics }; _completedPhases.Add(phase); _logger.LogDebug( "Completed phase {PhaseName} for scan {ScanId} in {DurationMs}ms (success={Success})", phaseName, _scanId, phase.DurationMs, success); } } /// /// Set result digests. /// public void SetDigests( string findingsSha256, string? vexBundleSha256 = null, string? proofBundleSha256 = null, string? sbomSha256 = null) { _findingsSha256 = findingsSha256; _vexBundleSha256 = vexBundleSha256; _proofBundleSha256 = proofBundleSha256; _sbomSha256 = sbomSha256; } /// /// Set policy reference. /// public void SetPolicy(string? policyDigest, string? feedSnapshotId = null) { _policyDigest = policyDigest; _feedSnapshotId = feedSnapshotId; } /// /// Set artifact counts. /// public void SetCounts(int? packageCount = null, int? findingCount = null, int? vexDecisionCount = null) { _packageCount = packageCount; _findingCount = findingCount; _vexDecisionCount = vexDecisionCount; } /// /// Set additional metadata. /// public void SetMetadata( Guid? surfaceId = null, string? replayManifestHash = null, string? scannerImageDigest = null, bool isReplay = false) { _surfaceId = surfaceId; _replayManifestHash = replayManifestHash; _scannerImageDigest = scannerImageDigest; _isReplay = isReplay; } /// /// Complete metrics collection and persist. /// public async Task CompleteAsync(CancellationToken cancellationToken = default) { _totalStopwatch.Stop(); var finishedAt = DateTimeOffset.UtcNow; // Calculate phase timings var phases = BuildPhaseTimings(); var metrics = new ScanMetrics { MetricsId = _metricsId, ScanId = _scanId, TenantId = _tenantId, SurfaceId = _surfaceId, ArtifactDigest = _artifactDigest, ArtifactType = _artifactType, ReplayManifestHash = _replayManifestHash, FindingsSha256 = _findingsSha256 ?? string.Empty, VexBundleSha256 = _vexBundleSha256, ProofBundleSha256 = _proofBundleSha256, SbomSha256 = _sbomSha256, PolicyDigest = _policyDigest, FeedSnapshotId = _feedSnapshotId, StartedAt = _startedAt, FinishedAt = finishedAt, Phases = phases, PackageCount = _packageCount, FindingCount = _findingCount, VexDecisionCount = _vexDecisionCount, ScannerVersion = _scannerVersion, ScannerImageDigest = _scannerImageDigest, IsReplay = _isReplay }; try { await _repository.SaveAsync(metrics, cancellationToken); await _repository.SavePhasesAsync(_completedPhases, cancellationToken); _logger.LogInformation( "Saved metrics for scan {ScanId}: TTE={TteMms}ms, Packages={Packages}, Findings={Findings}", _scanId, metrics.TotalDurationMs, _packageCount, _findingCount); } catch (Exception ex) { _logger.LogError(ex, "Failed to save metrics for scan {ScanId}", _scanId); throw; } } private ScanPhaseTimings BuildPhaseTimings() { lock (_lock) { int GetPhaseDuration(string name) => _completedPhases .Where(p => p.PhaseName == name) .Sum(p => p.DurationMs); return new ScanPhaseTimings { IngestMs = GetPhaseDuration(ScanPhaseNames.Ingest), AnalyzeMs = GetPhaseDuration(ScanPhaseNames.Analyze), ReachabilityMs = GetPhaseDuration(ScanPhaseNames.Reachability), VexMs = GetPhaseDuration(ScanPhaseNames.Vex), SignMs = GetPhaseDuration(ScanPhaseNames.Sign), PublishMs = GetPhaseDuration(ScanPhaseNames.Publish) }; } } public void Dispose() { if (_disposed) return; _disposed = true; _totalStopwatch.Stop(); } private sealed class PhaseTracker : IDisposable { private readonly ScanMetricsCollector _collector; private readonly string _phaseName; private bool _disposed; public DateTimeOffset StartedAt { get; } public PhaseTracker(ScanMetricsCollector collector, string phaseName, DateTimeOffset startedAt) { _collector = collector; _phaseName = phaseName; StartedAt = startedAt; } public void Dispose() { if (_disposed) return; _disposed = true; _collector.CompletePhase(_phaseName); } } private sealed class NoOpDisposable : IDisposable { public static readonly NoOpDisposable Instance = new(); private NoOpDisposable() { } public void Dispose() { } } }