Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying scored findings with filtering, pagination, and explainability.
|
||||
/// </summary>
|
||||
public interface IScoredFindingsQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Queries scored findings with filters and pagination.
|
||||
/// </summary>
|
||||
Task<ScoredFindingsQueryResult> QueryAsync(
|
||||
ScoredFindingsQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single scored finding by ID.
|
||||
/// </summary>
|
||||
Task<ScoredFinding?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the score explanation for a finding.
|
||||
/// </summary>
|
||||
Task<ScoredFindingExplanation?> GetExplanationAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
Guid? explanationId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a risk summary for a tenant.
|
||||
/// </summary>
|
||||
Task<RiskSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the severity distribution for a tenant.
|
||||
/// </summary>
|
||||
Task<SeverityDistribution> GetSeverityDistributionAsync(
|
||||
string tenantId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets top findings by risk score.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ScoredFinding>> GetTopRisksAsync(
|
||||
string tenantId,
|
||||
int count = 10,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for exporting scored findings to various formats.
|
||||
/// </summary>
|
||||
public sealed class ScoredFindingsExportService : IScoredFindingsExportService
|
||||
{
|
||||
private readonly IScoredFindingsQueryService _queryService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ScoredFindingsExportService> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public ScoredFindingsExportService(
|
||||
IScoredFindingsQueryService queryService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ScoredFindingsExportService> logger)
|
||||
{
|
||||
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ExportResult> ExportAsync(
|
||||
ScoredFindingsExportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
|
||||
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var query = new ScoredFindingsQuery
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
PolicyVersion = request.PolicyVersion,
|
||||
MinScore = request.MinScore,
|
||||
MaxScore = request.MaxScore,
|
||||
Severities = request.Severities,
|
||||
Statuses = request.Statuses,
|
||||
Limit = request.MaxRecords ?? 10000,
|
||||
SortBy = ScoredFindingsSortField.RiskScore,
|
||||
Descending = true
|
||||
};
|
||||
|
||||
var result = await _queryService.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var exportData = request.Format switch
|
||||
{
|
||||
ExportFormat.Json => ExportToJson(result.Findings, request),
|
||||
ExportFormat.Ndjson => ExportToNdjson(result.Findings, request),
|
||||
ExportFormat.Csv => ExportToCsv(result.Findings, request),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(request.Format))
|
||||
};
|
||||
|
||||
var endTime = _timeProvider.GetUtcNow();
|
||||
var duration = endTime - startTime;
|
||||
|
||||
LedgerMetrics.RecordScoredFindingsExport(request.TenantId, result.Findings.Count, duration.TotalSeconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exported {Count} scored findings for tenant {TenantId} in {Duration:F2}s",
|
||||
result.Findings.Count, request.TenantId, duration.TotalSeconds);
|
||||
|
||||
return new ExportResult
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
Format = request.Format,
|
||||
RecordCount = result.Findings.Count,
|
||||
Data = exportData,
|
||||
ContentType = GetContentType(request.Format),
|
||||
GeneratedAt = endTime,
|
||||
DurationMs = (long)duration.TotalMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Stream> ExportToStreamAsync(
|
||||
ScoredFindingsExportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await ExportAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return new MemoryStream(result.Data);
|
||||
}
|
||||
|
||||
private static byte[] ExportToJson(IReadOnlyList<ScoredFinding> findings, ScoredFindingsExportRequest request)
|
||||
{
|
||||
var envelope = new JsonObject
|
||||
{
|
||||
["version"] = "1.0",
|
||||
["tenant_id"] = request.TenantId,
|
||||
["generated_at"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["record_count"] = findings.Count,
|
||||
["findings"] = new JsonArray(findings.Select(MapToJsonNode).ToArray())
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
|
||||
}
|
||||
|
||||
private static byte[] ExportToNdjson(IReadOnlyList<ScoredFinding> findings, ScoredFindingsExportRequest request)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
sb.AppendLine(JsonSerializer.Serialize(MapToExportRecord(finding), JsonOptions));
|
||||
}
|
||||
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||
}
|
||||
|
||||
private static byte[] ExportToCsv(IReadOnlyList<ScoredFinding> findings, ScoredFindingsExportRequest request)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("tenant_id,finding_id,policy_version,status,risk_score,risk_severity,risk_profile_version,updated_at");
|
||||
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
sb.AppendLine(string.Join(",",
|
||||
EscapeCsv(finding.TenantId),
|
||||
EscapeCsv(finding.FindingId),
|
||||
EscapeCsv(finding.PolicyVersion),
|
||||
EscapeCsv(finding.Status),
|
||||
finding.RiskScore?.ToString("F4") ?? "",
|
||||
EscapeCsv(finding.RiskSeverity ?? ""),
|
||||
EscapeCsv(finding.RiskProfileVersion ?? ""),
|
||||
finding.UpdatedAt.ToString("O")));
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||
}
|
||||
|
||||
private static JsonNode MapToJsonNode(ScoredFinding finding)
|
||||
{
|
||||
return JsonSerializer.SerializeToNode(MapToExportRecord(finding), JsonOptions)!;
|
||||
}
|
||||
|
||||
private static object MapToExportRecord(ScoredFinding finding)
|
||||
{
|
||||
return new
|
||||
{
|
||||
finding.TenantId,
|
||||
finding.FindingId,
|
||||
finding.PolicyVersion,
|
||||
finding.Status,
|
||||
finding.RiskScore,
|
||||
finding.RiskSeverity,
|
||||
finding.RiskProfileVersion,
|
||||
finding.RiskExplanationId,
|
||||
finding.ExplainRef,
|
||||
finding.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
{
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string GetContentType(ExportFormat format) => format switch
|
||||
{
|
||||
ExportFormat.Json => "application/json",
|
||||
ExportFormat.Ndjson => "application/x-ndjson",
|
||||
ExportFormat.Csv => "text/csv",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for exporting scored findings.
|
||||
/// </summary>
|
||||
public interface IScoredFindingsExportService
|
||||
{
|
||||
Task<ExportResult> ExportAsync(
|
||||
ScoredFindingsExportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Stream> ExportToStreamAsync(
|
||||
ScoredFindingsExportRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for exporting scored findings.
|
||||
/// </summary>
|
||||
public sealed record ScoredFindingsExportRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public string? PolicyVersion { get; init; }
|
||||
public decimal? MinScore { get; init; }
|
||||
public decimal? MaxScore { get; init; }
|
||||
public IReadOnlyList<string>? Severities { get; init; }
|
||||
public IReadOnlyList<string>? Statuses { get; init; }
|
||||
public int? MaxRecords { get; init; }
|
||||
public ExportFormat Format { get; init; } = ExportFormat.Json;
|
||||
public bool IncludeExplanations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export formats.
|
||||
/// </summary>
|
||||
public enum ExportFormat
|
||||
{
|
||||
Json,
|
||||
Ndjson,
|
||||
Csv
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an export operation.
|
||||
/// </summary>
|
||||
public sealed record ExportResult
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required ExportFormat Format { get; init; }
|
||||
public required int RecordCount { get; init; }
|
||||
public required byte[] Data { get; init; }
|
||||
public required string ContentType { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for scored findings.
|
||||
/// </summary>
|
||||
public sealed record ScoredFindingsQuery
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public string? PolicyVersion { get; init; }
|
||||
public decimal? MinScore { get; init; }
|
||||
public decimal? MaxScore { get; init; }
|
||||
public IReadOnlyList<string>? Severities { get; init; }
|
||||
public IReadOnlyList<string>? Statuses { get; init; }
|
||||
public string? ProfileId { get; init; }
|
||||
public DateTimeOffset? ScoredAfter { get; init; }
|
||||
public DateTimeOffset? ScoredBefore { get; init; }
|
||||
public string? Cursor { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
public ScoredFindingsSortField SortBy { get; init; } = ScoredFindingsSortField.RiskScore;
|
||||
public bool Descending { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort fields for scored findings queries.
|
||||
/// </summary>
|
||||
public enum ScoredFindingsSortField
|
||||
{
|
||||
RiskScore,
|
||||
RiskSeverity,
|
||||
UpdatedAt,
|
||||
FindingId
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a scored findings query.
|
||||
/// </summary>
|
||||
public sealed record ScoredFindingsQueryResult
|
||||
{
|
||||
public required IReadOnlyList<ScoredFinding> Findings { get; init; }
|
||||
public string? NextCursor { get; init; }
|
||||
public bool HasMore { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A finding with risk score information.
|
||||
/// </summary>
|
||||
public sealed record ScoredFinding
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string FindingId { get; init; }
|
||||
public required string PolicyVersion { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public decimal? RiskScore { get; init; }
|
||||
public string? RiskSeverity { get; init; }
|
||||
public string? RiskProfileVersion { get; init; }
|
||||
public Guid? RiskExplanationId { get; init; }
|
||||
public string? ExplainRef { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed score explanation for a finding.
|
||||
/// </summary>
|
||||
public sealed record ScoredFindingExplanation
|
||||
{
|
||||
public required string FindingId { get; init; }
|
||||
public required string ProfileId { get; init; }
|
||||
public required string ProfileVersion { get; init; }
|
||||
public decimal RawScore { get; init; }
|
||||
public decimal NormalizedScore { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required IReadOnlyDictionary<string, decimal> SignalValues { get; init; }
|
||||
public required IReadOnlyDictionary<string, decimal> SignalContributions { get; init; }
|
||||
public string? OverrideApplied { get; init; }
|
||||
public string? OverrideReason { get; init; }
|
||||
public DateTimeOffset ScoredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Severity distribution summary.
|
||||
/// </summary>
|
||||
public sealed record SeverityDistribution
|
||||
{
|
||||
public int Critical { get; init; }
|
||||
public int High { get; init; }
|
||||
public int Medium { get; init; }
|
||||
public int Low { get; init; }
|
||||
public int Informational { get; init; }
|
||||
public int Unscored { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score distribution buckets.
|
||||
/// </summary>
|
||||
public sealed record ScoreDistribution
|
||||
{
|
||||
public int Score0To20 { get; init; }
|
||||
public int Score20To40 { get; init; }
|
||||
public int Score40To60 { get; init; }
|
||||
public int Score60To80 { get; init; }
|
||||
public int Score80To100 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk summary for a tenant.
|
||||
/// </summary>
|
||||
public sealed record RiskSummary
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public int TotalFindings { get; init; }
|
||||
public int ScoredFindings { get; init; }
|
||||
public decimal AverageScore { get; init; }
|
||||
public decimal MaxScore { get; init; }
|
||||
public required SeverityDistribution SeverityDistribution { get; init; }
|
||||
public required ScoreDistribution ScoreDistribution { get; init; }
|
||||
public DateTimeOffset CalculatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying scored findings with filtering, pagination, and explainability.
|
||||
/// </summary>
|
||||
public sealed class ScoredFindingsQueryService : IScoredFindingsQueryService
|
||||
{
|
||||
private readonly IFindingProjectionRepository _repository;
|
||||
private readonly IRiskExplanationStore _explanationStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ScoredFindingsQueryService> _logger;
|
||||
|
||||
public ScoredFindingsQueryService(
|
||||
IFindingProjectionRepository repository,
|
||||
IRiskExplanationStore explanationStore,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ScoredFindingsQueryService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_explanationStore = explanationStore ?? throw new ArgumentNullException(nameof(explanationStore));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ScoredFindingsQueryResult> QueryAsync(
|
||||
ScoredFindingsQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.TenantId);
|
||||
|
||||
var (projections, totalCount) = await _repository.QueryScoredAsync(query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var findings = projections
|
||||
.Select(MapToScoredFinding)
|
||||
.ToList();
|
||||
|
||||
var hasMore = findings.Count == query.Limit && totalCount > query.Limit;
|
||||
var nextCursor = hasMore && findings.Count > 0
|
||||
? EncodeCursor(findings[^1])
|
||||
: null;
|
||||
|
||||
return new ScoredFindingsQueryResult
|
||||
{
|
||||
Findings = findings,
|
||||
NextCursor = nextCursor,
|
||||
HasMore = hasMore,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ScoredFinding?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
var projection = await _repository.GetAsync(
|
||||
tenantId,
|
||||
findingId,
|
||||
policyVersion ?? "default",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return projection is null ? null : MapToScoredFinding(projection);
|
||||
}
|
||||
|
||||
public async Task<ScoredFindingExplanation?> GetExplanationAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
Guid? explanationId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
|
||||
|
||||
var explanation = await _explanationStore.GetAsync(
|
||||
tenantId,
|
||||
findingId,
|
||||
explanationId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return explanation;
|
||||
}
|
||||
|
||||
public async Task<RiskSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var severityDist = await _repository.GetSeverityDistributionAsync(tenantId, policyVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var scoreDist = await _repository.GetScoreDistributionAsync(tenantId, policyVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var (total, scored, avgScore, maxScore) = await _repository.GetRiskAggregatesAsync(tenantId, policyVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new RiskSummary
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TotalFindings = total,
|
||||
ScoredFindings = scored,
|
||||
AverageScore = avgScore,
|
||||
MaxScore = maxScore,
|
||||
SeverityDistribution = severityDist,
|
||||
ScoreDistribution = scoreDist,
|
||||
CalculatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SeverityDistribution> GetSeverityDistributionAsync(
|
||||
string tenantId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
return await _repository.GetSeverityDistributionAsync(tenantId, policyVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScoredFinding>> GetTopRisksAsync(
|
||||
string tenantId,
|
||||
int count = 10,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var query = new ScoredFindingsQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PolicyVersion = policyVersion,
|
||||
Limit = count,
|
||||
SortBy = ScoredFindingsSortField.RiskScore,
|
||||
Descending = true
|
||||
};
|
||||
|
||||
var result = await QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
return result.Findings;
|
||||
}
|
||||
|
||||
private static ScoredFinding MapToScoredFinding(FindingProjection projection)
|
||||
{
|
||||
return new ScoredFinding
|
||||
{
|
||||
TenantId = projection.TenantId,
|
||||
FindingId = projection.FindingId,
|
||||
PolicyVersion = projection.PolicyVersion,
|
||||
Status = projection.Status,
|
||||
RiskScore = projection.RiskScore,
|
||||
RiskSeverity = projection.RiskSeverity,
|
||||
RiskProfileVersion = projection.RiskProfileVersion,
|
||||
RiskExplanationId = projection.RiskExplanationId,
|
||||
ExplainRef = projection.ExplainRef,
|
||||
UpdatedAt = projection.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string EncodeCursor(ScoredFinding finding)
|
||||
{
|
||||
// Simple cursor encoding: findingId|score|updatedAt
|
||||
var cursor = $"{finding.FindingId}|{finding.RiskScore}|{finding.UpdatedAt:O}";
|
||||
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for risk score explanations.
|
||||
/// </summary>
|
||||
public interface IRiskExplanationStore
|
||||
{
|
||||
Task<ScoredFindingExplanation?> GetAsync(
|
||||
string tenantId,
|
||||
string findingId,
|
||||
Guid? explanationId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task StoreAsync(
|
||||
string tenantId,
|
||||
ScoredFindingExplanation explanation,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for emitting and updating risk scoring metrics.
|
||||
/// Supports dashboards for scoring latency, severity distribution, result freshness, and provider gaps.
|
||||
/// </summary>
|
||||
public sealed class ScoringMetricsService : IScoringMetricsService
|
||||
{
|
||||
private readonly IFindingProjectionRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ScoringMetricsService> _logger;
|
||||
|
||||
public ScoringMetricsService(
|
||||
IFindingProjectionRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<ScoringMetricsService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task RefreshSeverityDistributionAsync(
|
||||
string tenantId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var distribution = await _repository.GetSeverityDistributionAsync(tenantId, policyVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
LedgerMetrics.UpdateSeverityDistribution(
|
||||
tenantId,
|
||||
policyVersion,
|
||||
distribution.Critical,
|
||||
distribution.High,
|
||||
distribution.Medium,
|
||||
distribution.Low,
|
||||
distribution.Unscored);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Updated severity distribution for tenant {TenantId}: Critical={Critical}, High={High}, Medium={Medium}, Low={Low}, Unscored={Unscored}",
|
||||
tenantId, distribution.Critical, distribution.High, distribution.Medium, distribution.Low, distribution.Unscored);
|
||||
}
|
||||
|
||||
public void RecordScoringOperation(
|
||||
string tenantId,
|
||||
string? policyVersion,
|
||||
TimeSpan duration,
|
||||
ScoringResult result)
|
||||
{
|
||||
LedgerMetrics.RecordScoringLatency(duration, tenantId, policyVersion, result.ToString().ToLowerInvariant());
|
||||
LedgerMetrics.UpdateScoreFreshness(tenantId, 0);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded scoring operation for tenant {TenantId}: Duration={Duration:F3}s, Result={Result}",
|
||||
tenantId, duration.TotalSeconds, result);
|
||||
}
|
||||
|
||||
public void RecordProviderGap(
|
||||
string tenantId,
|
||||
string? provider,
|
||||
string reason)
|
||||
{
|
||||
LedgerMetrics.RecordScoringProviderGap(tenantId, provider, reason);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Provider gap recorded for tenant {TenantId}: Provider={Provider}, Reason={Reason}",
|
||||
tenantId, provider ?? "unknown", reason);
|
||||
}
|
||||
|
||||
public void UpdateScoreFreshness(string tenantId, DateTimeOffset lastScoringTime)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var freshness = (now - lastScoringTime).TotalSeconds;
|
||||
LedgerMetrics.UpdateScoreFreshness(tenantId, freshness);
|
||||
}
|
||||
|
||||
public async Task<ScoringMetricsSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var severityDist = await _repository.GetSeverityDistributionAsync(tenantId, policyVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var scoreDist = await _repository.GetScoreDistributionAsync(tenantId, policyVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var (total, scored, avgScore, maxScore) = await _repository.GetRiskAggregatesAsync(tenantId, policyVersion, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var coveragePercent = total > 0 ? (decimal)scored / total * 100 : 0;
|
||||
|
||||
return new ScoringMetricsSummary
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PolicyVersion = policyVersion ?? "default",
|
||||
TotalFindings = total,
|
||||
ScoredFindings = scored,
|
||||
UnscoredFindings = total - scored,
|
||||
CoveragePercent = coveragePercent,
|
||||
AverageScore = avgScore,
|
||||
MaxScore = maxScore,
|
||||
SeverityDistribution = severityDist,
|
||||
ScoreDistribution = scoreDist,
|
||||
CalculatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for scoring metrics service.
|
||||
/// </summary>
|
||||
public interface IScoringMetricsService
|
||||
{
|
||||
Task RefreshSeverityDistributionAsync(
|
||||
string tenantId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
void RecordScoringOperation(
|
||||
string tenantId,
|
||||
string? policyVersion,
|
||||
TimeSpan duration,
|
||||
ScoringResult result);
|
||||
|
||||
void RecordProviderGap(
|
||||
string tenantId,
|
||||
string? provider,
|
||||
string reason);
|
||||
|
||||
void UpdateScoreFreshness(string tenantId, DateTimeOffset lastScoringTime);
|
||||
|
||||
Task<ScoringMetricsSummary> GetSummaryAsync(
|
||||
string tenantId,
|
||||
string? policyVersion = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a scoring operation.
|
||||
/// </summary>
|
||||
public enum ScoringResult
|
||||
{
|
||||
Success,
|
||||
PartialSuccess,
|
||||
ProviderUnavailable,
|
||||
PolicyMissing,
|
||||
ValidationFailed,
|
||||
Timeout,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of scoring metrics for a tenant.
|
||||
/// </summary>
|
||||
public sealed record ScoringMetricsSummary
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string PolicyVersion { get; init; }
|
||||
public int TotalFindings { get; init; }
|
||||
public int ScoredFindings { get; init; }
|
||||
public int UnscoredFindings { get; init; }
|
||||
public decimal CoveragePercent { get; init; }
|
||||
public decimal AverageScore { get; init; }
|
||||
public decimal MaxScore { get; init; }
|
||||
public required SeverityDistribution SeverityDistribution { get; init; }
|
||||
public required ScoreDistribution ScoreDistribution { get; init; }
|
||||
public DateTimeOffset CalculatedAt { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user