feat(metrics): Implement scan metrics repository and PostgreSQL integration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- 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.
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user