Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.Worker/Metrics/ScanMetricsCollector.cs
master 415eff1207
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
feat(metrics): Implement scan metrics repository and PostgreSQL integration
- Added IScanMetricsRepository interface for scan metrics persistence and retrieval.
- Implemented PostgresScanMetricsRepository for PostgreSQL database interactions, including methods for saving and retrieving scan metrics and execution phases.
- Introduced methods for obtaining TTE statistics and recent scans for tenants.
- Implemented deletion of old metrics for retention purposes.

test(tests): Add SCA Failure Catalogue tests for FC6-FC10

- Created ScaCatalogueDeterminismTests to validate determinism properties of SCA Failure Catalogue fixtures.
- Developed ScaFailureCatalogueTests to ensure correct handling of specific failure modes in the scanner.
- Included tests for manifest validation, file existence, and expected findings across multiple failure cases.

feat(telemetry): Integrate scan completion metrics into the pipeline

- Introduced IScanCompletionMetricsIntegration interface and ScanCompletionMetricsIntegration class to record metrics upon scan completion.
- Implemented proof coverage and TTE metrics recording with logging for scan completion summaries.
2025-12-16 14:00:35 +02:00

322 lines
10 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Collects and persists scan metrics during execution.
/// Thread-safe for concurrent phase tracking.
/// </summary>
public sealed class ScanMetricsCollector : IDisposable
{
private readonly IScanMetricsRepository _repository;
private readonly ILogger<ScanMetricsCollector> _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<string, PhaseTracker> _phases = new();
private readonly List<ExecutionPhase> _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<ScanMetricsCollector> 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();
}
/// <summary>
/// Gets the metrics ID for this scan.
/// </summary>
public Guid MetricsId => _metricsId;
/// <summary>
/// Start collecting metrics.
/// </summary>
public void Start()
{
_startedAt = DateTimeOffset.UtcNow;
_totalStopwatch.Start();
_logger.LogDebug("Started metrics collection for scan {ScanId}", _scanId);
}
/// <summary>
/// Start tracking a phase.
/// </summary>
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;
}
}
/// <summary>
/// Complete a phase with success.
/// </summary>
public void CompletePhase(string phaseName, Dictionary<string, object>? metrics = null)
{
CompletePhaseInternal(phaseName, success: true, errorCode: null, errorMessage: null, metrics);
}
/// <summary>
/// Complete a phase with failure.
/// </summary>
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<string, object>? 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);
}
}
/// <summary>
/// Set result digests.
/// </summary>
public void SetDigests(
string findingsSha256,
string? vexBundleSha256 = null,
string? proofBundleSha256 = null,
string? sbomSha256 = null)
{
_findingsSha256 = findingsSha256;
_vexBundleSha256 = vexBundleSha256;
_proofBundleSha256 = proofBundleSha256;
_sbomSha256 = sbomSha256;
}
/// <summary>
/// Set policy reference.
/// </summary>
public void SetPolicy(string? policyDigest, string? feedSnapshotId = null)
{
_policyDigest = policyDigest;
_feedSnapshotId = feedSnapshotId;
}
/// <summary>
/// Set artifact counts.
/// </summary>
public void SetCounts(int? packageCount = null, int? findingCount = null, int? vexDecisionCount = null)
{
_packageCount = packageCount;
_findingCount = findingCount;
_vexDecisionCount = vexDecisionCount;
}
/// <summary>
/// Set additional metadata.
/// </summary>
public void SetMetadata(
Guid? surfaceId = null,
string? replayManifestHash = null,
string? scannerImageDigest = null,
bool isReplay = false)
{
_surfaceId = surfaceId;
_replayManifestHash = replayManifestHash;
_scannerImageDigest = scannerImageDigest;
_isReplay = isReplay;
}
/// <summary>
/// Complete metrics collection and persist.
/// </summary>
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() { }
}
}