feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies. - Documented roles and guidelines in AGENTS.md for Scheduler module. - Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs. - Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics. - Developed API endpoints for managing resolver jobs and retrieving metrics. - Defined models for resolver job requests and responses. - Integrated dependency injection for resolver job services. - Implemented ImpactIndexSnapshot for persisting impact index data. - Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring. - Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService. - Created dotnet-filter.sh script to handle command-line arguments for dotnet. - Established nuget-prime project for managing package downloads.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.InMemory;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Infrastructure;
|
||||
|
||||
public class InMemoryLedgerEventRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetEvidenceReferencesAsync_returns_only_events_with_refs()
|
||||
{
|
||||
var repo = new InMemoryLedgerEventRepository();
|
||||
var tenant = "tenant-1";
|
||||
var findingId = "finding-123";
|
||||
|
||||
var withEvidence = CreateRecord(tenant, findingId, sequence: 1, evidenceRef: "bundle-1");
|
||||
var withoutEvidence = CreateRecord(tenant, findingId, sequence: 2, evidenceRef: null);
|
||||
await repo.AppendAsync(withEvidence, CancellationToken.None);
|
||||
await repo.AppendAsync(withoutEvidence, CancellationToken.None);
|
||||
|
||||
var results = await repo.GetEvidenceReferencesAsync(tenant, findingId, CancellationToken.None);
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].EvidenceBundleRef.Should().Be("bundle-1");
|
||||
results[0].EventId.Should().Be(withEvidence.EventId);
|
||||
}
|
||||
|
||||
private static LedgerEventRecord CreateRecord(string tenant, string findingId, long sequence, string? evidenceRef)
|
||||
{
|
||||
var body = new JsonObject { ["status"] = "affected" };
|
||||
return new LedgerEventRecord(
|
||||
tenant,
|
||||
Guid.NewGuid(),
|
||||
sequence,
|
||||
Guid.NewGuid(),
|
||||
"finding.status.changed",
|
||||
"policy-v1",
|
||||
findingId,
|
||||
"artifact-1",
|
||||
null,
|
||||
"actor-1",
|
||||
"operator",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
body,
|
||||
"hash-event",
|
||||
"hash-prev",
|
||||
"hash-leaf",
|
||||
"canon",
|
||||
evidenceRef);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Observability;
|
||||
|
||||
public class LedgerMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordProjectionApply_emits_histogram_and_counter_with_tags()
|
||||
{
|
||||
var histogramValues = new List<Measurement<double>>();
|
||||
var counterValues = new List<Measurement<long>>();
|
||||
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter.Name == "StellaOps.Findings.Ledger")
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, measurement, _) =>
|
||||
{
|
||||
if (instrument.Name is "ledger_projection_apply_seconds" or "ledger_projection_lag_seconds")
|
||||
{
|
||||
histogramValues.Add(measurement);
|
||||
}
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, _) =>
|
||||
{
|
||||
if (instrument.Name == "ledger_projection_events_total")
|
||||
{
|
||||
counterValues.Add(measurement);
|
||||
}
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
LedgerMetrics.RecordProjectionApply(
|
||||
TimeSpan.FromMilliseconds(40),
|
||||
1.2,
|
||||
"tenant-x",
|
||||
"finding.status.changed",
|
||||
"v1.0",
|
||||
"affected");
|
||||
|
||||
histogramValues.Should().NotBeEmpty();
|
||||
counterValues.Should().NotBeEmpty();
|
||||
|
||||
var tags = histogramValues.First().Tags.ToDictionary(kv => kv.Key, kv => kv.Value?.ToString());
|
||||
tags["tenant"].Should().Be("tenant-x");
|
||||
tags["event_type"].Should().Be("finding.status.changed");
|
||||
tags["policy_version"].Should().Be("v1.0");
|
||||
tags["evaluation_status"].Should().Be("affected");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Observability;
|
||||
|
||||
public class LedgerTelemetryTests
|
||||
{
|
||||
[Fact]
|
||||
public void StartLedgerAppend_sets_core_tags()
|
||||
{
|
||||
using var listener = CreateListener();
|
||||
var draft = CreateDraft();
|
||||
|
||||
using var activity = LedgerTelemetry.StartLedgerAppend(draft);
|
||||
|
||||
activity.Should().NotBeNull();
|
||||
activity!.DisplayName.Should().Be("Ledger.Append");
|
||||
activity.GetTagItem("tenant").Should().Be(draft.TenantId);
|
||||
activity.GetTagItem("chain_id").Should().Be(draft.ChainId);
|
||||
activity.GetTagItem("event_type").Should().Be(draft.EventType);
|
||||
activity.GetTagItem("policy_version").Should().Be(draft.PolicyVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkAppendOutcome_sets_hashes_and_success_status()
|
||||
{
|
||||
using var listener = CreateListener();
|
||||
var draft = CreateDraft();
|
||||
var record = CreateRecord(draft);
|
||||
using var activity = LedgerTelemetry.StartLedgerAppend(draft);
|
||||
|
||||
LedgerTelemetry.MarkAppendOutcome(activity, record, TimeSpan.FromMilliseconds(12));
|
||||
|
||||
activity.Should().NotBeNull();
|
||||
activity!.Status.Should().Be(ActivityStatusCode.Ok);
|
||||
activity.GetTagItem("event_hash").Should().Be(record.EventHash);
|
||||
activity.GetTagItem("merkle_leaf_hash").Should().Be(record.MerkleLeafHash);
|
||||
activity.GetTagItem("duration_ms").Should().Be(12d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartProjectionApply_sets_projection_tags()
|
||||
{
|
||||
using var listener = CreateListener();
|
||||
var record = CreateRecord(CreateDraft());
|
||||
|
||||
using var activity = LedgerTelemetry.StartProjectionApply(record);
|
||||
|
||||
activity.Should().NotBeNull();
|
||||
activity!.DisplayName.Should().Be("Ledger.Projection.Apply");
|
||||
activity.GetTagItem("tenant").Should().Be(record.TenantId);
|
||||
activity.GetTagItem("finding_id").Should().Be(record.FindingId);
|
||||
}
|
||||
|
||||
private static ActivityListener CreateListener()
|
||||
{
|
||||
var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = source => source.Name == LedgerTelemetry.ActivitySourceName,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
|
||||
SampleUsingParentId = (ref ActivityCreationOptions<string> _) => ActivitySamplingResult.AllDataAndRecorded
|
||||
};
|
||||
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static LedgerEventDraft CreateDraft()
|
||||
{
|
||||
var payload = new JsonObject { ["payload"] = "value" };
|
||||
var envelope = new JsonObject { ["event"] = new JsonObject { ["id"] = Guid.NewGuid() } };
|
||||
return new LedgerEventDraft(
|
||||
"tenant-a",
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
Guid.NewGuid(),
|
||||
"finding.status.changed",
|
||||
"v1.0",
|
||||
"finding-123",
|
||||
"artifact-abc",
|
||||
null,
|
||||
"actor-1",
|
||||
"operator",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
payload,
|
||||
envelope,
|
||||
LedgerEventConstants.EmptyHash);
|
||||
}
|
||||
|
||||
private static LedgerEventRecord CreateRecord(LedgerEventDraft draft)
|
||||
{
|
||||
return new LedgerEventRecord(
|
||||
draft.TenantId,
|
||||
draft.ChainId,
|
||||
draft.SequenceNumber,
|
||||
draft.EventId,
|
||||
draft.EventType,
|
||||
draft.PolicyVersion,
|
||||
draft.FindingId,
|
||||
draft.ArtifactId,
|
||||
draft.SourceRunId,
|
||||
draft.ActorId,
|
||||
draft.ActorType,
|
||||
draft.OccurredAt,
|
||||
draft.RecordedAt,
|
||||
draft.Payload,
|
||||
"hash-event",
|
||||
draft.ProvidedPreviousHash ?? LedgerEventConstants.EmptyHash,
|
||||
"hash-leaf",
|
||||
"canonical-json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Observability;
|
||||
|
||||
public class LedgerTimelineTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmitLedgerAppended_writes_structured_log_with_event_id()
|
||||
{
|
||||
var logger = new TestLogger<LedgerTimelineTests>();
|
||||
using var activity = new Activity("test").Start();
|
||||
var record = CreateRecord();
|
||||
|
||||
LedgerTimeline.EmitLedgerAppended(logger, record, "evidence-123");
|
||||
|
||||
logger.Entries.Should().HaveCount(1);
|
||||
var entry = logger.Entries.First();
|
||||
entry.EventId.Name.Should().Be("ledger.event.appended");
|
||||
entry.EventId.Id.Should().Be(6101);
|
||||
|
||||
var state = AsDictionary(entry.State);
|
||||
state["Tenant"].Should().Be(record.TenantId);
|
||||
state["EvidenceRef"].Should().Be("evidence-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitProjectionUpdated_writes_structured_log_with_status()
|
||||
{
|
||||
var logger = new TestLogger<LedgerTimelineTests>();
|
||||
using var activity = new Activity("test").Start();
|
||||
var record = CreateRecord();
|
||||
|
||||
LedgerTimeline.EmitProjectionUpdated(logger, record, "affected");
|
||||
|
||||
var entry = logger.Entries.Single();
|
||||
entry.EventId.Name.Should().Be("ledger.projection.updated");
|
||||
entry.EventId.Id.Should().Be(6201);
|
||||
|
||||
var state = AsDictionary(entry.State);
|
||||
state["Status"].Should().Be("affected");
|
||||
}
|
||||
|
||||
private static LedgerEventRecord CreateRecord()
|
||||
{
|
||||
var payload = new JsonObject { ["status"] = "affected" };
|
||||
return new LedgerEventRecord(
|
||||
"tenant-a",
|
||||
Guid.NewGuid(),
|
||||
1,
|
||||
Guid.NewGuid(),
|
||||
"finding.status.changed",
|
||||
"v1",
|
||||
"finding-1",
|
||||
"artifact-1",
|
||||
null,
|
||||
"actor-1",
|
||||
"operator",
|
||||
DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow,
|
||||
payload,
|
||||
"hash-event",
|
||||
"hash-prev",
|
||||
"hash-leaf",
|
||||
"canonical-json");
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?> AsDictionary(object state)
|
||||
{
|
||||
if (state is not IEnumerable<KeyValuePair<string, object?>> pairs)
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
return pairs.ToDictionary(k => k.Key, v => v.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Tests.Observability;
|
||||
|
||||
internal sealed class TestLogger<T> : ILogger<T>
|
||||
{
|
||||
private readonly ConcurrentBag<LogEntry> _entries = new();
|
||||
|
||||
public IReadOnlyCollection<LogEntry> Entries => _entries;
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
_entries.Add(new LogEntry(logLevel, eventId, state));
|
||||
}
|
||||
|
||||
internal sealed record LogEntry(LogLevel Level, EventId EventId, object State);
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Findings.Ledger\\StellaOps.Findings.Ledger.csproj" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.8.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
public sealed record EvidenceReference(
|
||||
Guid EventId,
|
||||
string EvidenceBundleRef,
|
||||
DateTimeOffset RecordedAt)
|
||||
{
|
||||
[JsonIgnore]
|
||||
public string EvidenceBundleRefNormalized => EvidenceBundleRef.Trim();
|
||||
}
|
||||
@@ -18,7 +18,8 @@ public sealed record LedgerEventDraft(
|
||||
DateTimeOffset RecordedAt,
|
||||
JsonObject Payload,
|
||||
JsonObject CanonicalEnvelope,
|
||||
string? ProvidedPreviousHash);
|
||||
string? ProvidedPreviousHash,
|
||||
string? EvidenceBundleReference = null);
|
||||
|
||||
public sealed record LedgerEventRecord(
|
||||
string TenantId,
|
||||
@@ -38,7 +39,8 @@ public sealed record LedgerEventRecord(
|
||||
string EventHash,
|
||||
string PreviousHash,
|
||||
string MerkleLeafHash,
|
||||
string CanonicalJson);
|
||||
string CanonicalJson,
|
||||
string? EvidenceBundleReference = null);
|
||||
|
||||
public sealed record LedgerChainHead(
|
||||
long SequenceNumber,
|
||||
|
||||
@@ -10,4 +10,6 @@ public interface ILedgerEventRepository
|
||||
Task<LedgerChainHead?> GetChainHeadAsync(string tenantId, Guid chainId, CancellationToken cancellationToken);
|
||||
|
||||
Task AppendAsync(LedgerEventRecord record, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,19 @@ public sealed class InMemoryLedgerEventRepository : ILedgerEventRepository
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var matches = _events.Values
|
||||
.Where(e => e.TenantId == tenantId
|
||||
&& string.Equals(e.FindingId, findingId, StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(e.EvidenceBundleReference))
|
||||
.OrderByDescending(e => e.RecordedAt)
|
||||
.Select(e => new EvidenceReference(e.EventId, e.EvidenceBundleReference!, e.RecordedAt))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EvidenceReference>>(matches);
|
||||
}
|
||||
|
||||
private static LedgerEventRecord Clone(LedgerEventRecord record)
|
||||
{
|
||||
var clonedBody = (JsonObject)record.EventBody.DeepClone();
|
||||
|
||||
@@ -24,7 +24,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash
|
||||
merkle_leaf_hash,
|
||||
evidence_bundle_ref
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND event_id = @event_id
|
||||
@@ -59,7 +60,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
event_body,
|
||||
event_hash,
|
||||
previous_hash,
|
||||
merkle_leaf_hash)
|
||||
merkle_leaf_hash,
|
||||
evidence_bundle_ref)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@chain_id,
|
||||
@@ -77,7 +79,8 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
@event_body,
|
||||
@event_hash,
|
||||
@previous_hash,
|
||||
@merkle_leaf_hash)
|
||||
@merkle_leaf_hash,
|
||||
@evidence_bundle_ref)
|
||||
""";
|
||||
|
||||
private readonly LedgerDataSource _dataSource;
|
||||
@@ -162,6 +165,7 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
command.Parameters.AddWithValue("event_hash", record.EventHash);
|
||||
command.Parameters.AddWithValue("previous_hash", record.PreviousHash);
|
||||
command.Parameters.AddWithValue("merkle_leaf_hash", record.MerkleLeafHash);
|
||||
command.Parameters.AddWithValue("evidence_bundle_ref", (object?)record.EvidenceBundleReference ?? DBNull.Value);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -194,6 +198,7 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
var eventHash = reader.GetString(12);
|
||||
var previousHash = reader.GetString(13);
|
||||
var merkleLeafHash = reader.GetString(14);
|
||||
var evidenceBundleRef = reader.IsDBNull(15) ? null : reader.GetString(15);
|
||||
|
||||
var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(eventBody);
|
||||
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(canonicalEnvelope);
|
||||
@@ -216,6 +221,37 @@ public sealed class PostgresLedgerEventRepository : ILedgerEventRepository
|
||||
eventHash,
|
||||
previousHash,
|
||||
merkleLeafHash,
|
||||
canonicalJson);
|
||||
canonicalJson,
|
||||
evidenceBundleRef);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EvidenceReference>> GetEvidenceReferencesAsync(string tenantId, string findingId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT event_id, evidence_bundle_ref, recorded_at
|
||||
FROM ledger_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND finding_id = @finding_id
|
||||
AND evidence_bundle_ref IS NOT NULL
|
||||
ORDER BY recorded_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("finding_id", findingId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var results = new List<EvidenceReference>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(new EvidenceReference(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetFieldValue<DateTimeOffset>(2)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -5,6 +6,7 @@ using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Infrastructure;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
using StellaOps.Findings.Ledger.Observability;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Projection;
|
||||
@@ -74,9 +76,21 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
|
||||
foreach (var record in batch)
|
||||
{
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["tenant"] = record.TenantId,
|
||||
["chainId"] = record.ChainId,
|
||||
["eventId"] = record.EventId,
|
||||
["eventType"] = record.EventType,
|
||||
["policyVersion"] = record.PolicyVersion
|
||||
});
|
||||
using var activity = LedgerTelemetry.StartProjectionApply(record);
|
||||
var applyStopwatch = Stopwatch.StartNew();
|
||||
string? evaluationStatus = null;
|
||||
|
||||
try
|
||||
{
|
||||
await ApplyAsync(record, stoppingToken).ConfigureAwait(false);
|
||||
evaluationStatus = await ApplyAsync(record, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
checkpoint = checkpoint with
|
||||
{
|
||||
@@ -86,13 +100,36 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
};
|
||||
|
||||
await _repository.SaveCheckpointAsync(checkpoint, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Projected ledger event {EventId} for tenant {Tenant} chain {ChainId} seq {Sequence} finding {FindingId}.",
|
||||
record.EventId,
|
||||
record.TenantId,
|
||||
record.ChainId,
|
||||
record.SequenceNumber,
|
||||
record.FindingId);
|
||||
activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok);
|
||||
|
||||
applyStopwatch.Stop();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var lagSeconds = Math.Max(0, (now - record.RecordedAt).TotalSeconds);
|
||||
LedgerMetrics.RecordProjectionApply(
|
||||
applyStopwatch.Elapsed,
|
||||
lagSeconds,
|
||||
record.TenantId,
|
||||
record.EventType,
|
||||
record.PolicyVersion,
|
||||
evaluationStatus ?? string.Empty);
|
||||
LedgerTimeline.EmitProjectionUpdated(_logger, record, evaluationStatus, evidenceBundleRef: null);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "projection_cancelled");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "projection_failed");
|
||||
_logger.LogError(ex, "Failed to project ledger event {EventId} for tenant {TenantId}.", record.EventId, record.TenantId);
|
||||
await DelayAsync(stoppingToken).ConfigureAwait(false);
|
||||
break;
|
||||
@@ -101,7 +138,7 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyAsync(LedgerEventRecord record, CancellationToken cancellationToken)
|
||||
private async Task<string?> ApplyAsync(LedgerEventRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await _repository.GetAsync(record.TenantId, record.FindingId, record.PolicyVersion, cancellationToken).ConfigureAwait(false);
|
||||
var evaluation = await _policyEvaluationService.EvaluateAsync(record, current, cancellationToken).ConfigureAwait(false);
|
||||
@@ -114,6 +151,8 @@ public sealed class LedgerProjectionWorker : BackgroundService
|
||||
{
|
||||
await _repository.InsertActionAsync(result.Action, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return evaluation.Status;
|
||||
}
|
||||
|
||||
private async Task DelayAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -15,6 +15,20 @@ internal static class LedgerMetrics
|
||||
"ledger_events_total",
|
||||
description: "Number of ledger events appended.");
|
||||
|
||||
private static readonly Histogram<double> ProjectionApplySeconds = Meter.CreateHistogram<double>(
|
||||
"ledger_projection_apply_seconds",
|
||||
unit: "s",
|
||||
description: "Duration to apply a ledger event to the finding projection.");
|
||||
|
||||
private static readonly Histogram<double> ProjectionLagSeconds = Meter.CreateHistogram<double>(
|
||||
"ledger_projection_lag_seconds",
|
||||
unit: "s",
|
||||
description: "Lag between ledger recorded_at and projection application time.");
|
||||
|
||||
private static readonly Counter<long> ProjectionEventsTotal = Meter.CreateCounter<long>(
|
||||
"ledger_projection_events_total",
|
||||
description: "Number of ledger events applied to projections.");
|
||||
|
||||
public static void RecordWriteSuccess(TimeSpan duration, string? tenantId, string? eventType, string? source)
|
||||
{
|
||||
var tags = new TagList
|
||||
@@ -27,4 +41,25 @@ internal static class LedgerMetrics
|
||||
WriteLatencySeconds.Record(duration.TotalSeconds, tags);
|
||||
EventsTotal.Add(1, tags);
|
||||
}
|
||||
|
||||
public static void RecordProjectionApply(
|
||||
TimeSpan duration,
|
||||
double lagSeconds,
|
||||
string? tenantId,
|
||||
string? eventType,
|
||||
string? policyVersion,
|
||||
string? evaluationStatus)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", tenantId ?? string.Empty },
|
||||
{ "event_type", eventType ?? string.Empty },
|
||||
{ "policy_version", policyVersion ?? string.Empty },
|
||||
{ "evaluation_status", evaluationStatus ?? string.Empty }
|
||||
};
|
||||
|
||||
ProjectionApplySeconds.Record(duration.TotalSeconds, tags);
|
||||
ProjectionLagSeconds.Record(lagSeconds, tags);
|
||||
ProjectionEventsTotal.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Diagnostics;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Centralised ActivitySource and tagging helpers for ledger telemetry.
|
||||
/// Keeps tags consistent across writer, projector, and query surfaces.
|
||||
/// </summary>
|
||||
internal static class LedgerTelemetry
|
||||
{
|
||||
internal const string ActivitySourceName = "StellaOps.Findings.Ledger";
|
||||
|
||||
private static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
public static Activity? StartLedgerAppend(LedgerEventDraft draft)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Ledger.Append", ActivityKind.Internal);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("tenant", draft.TenantId);
|
||||
activity.SetTag("chain_id", draft.ChainId);
|
||||
activity.SetTag("sequence", draft.SequenceNumber);
|
||||
activity.SetTag("event_id", draft.EventId);
|
||||
activity.SetTag("event_type", draft.EventType);
|
||||
activity.SetTag("actor_id", draft.ActorId);
|
||||
activity.SetTag("actor_type", draft.ActorType);
|
||||
activity.SetTag("policy_version", draft.PolicyVersion);
|
||||
activity.SetTag("source", draft.SourceRunId.HasValue ? "policy_run" : "workflow");
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
public static void MarkAppendOutcome(Activity? activity, LedgerEventRecord record, TimeSpan duration)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetTag("event_hash", record.EventHash);
|
||||
activity.SetTag("previous_hash", record.PreviousHash);
|
||||
activity.SetTag("merkle_leaf_hash", record.MerkleLeafHash);
|
||||
activity.SetTag("duration_ms", duration.TotalMilliseconds);
|
||||
activity.SetStatus(ActivityStatusCode.Ok);
|
||||
}
|
||||
|
||||
public static void MarkError(Activity? activity, string reason)
|
||||
{
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity.SetStatus(ActivityStatusCode.Error, reason);
|
||||
}
|
||||
|
||||
public static Activity? StartProjectionApply(LedgerEventRecord record)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("Ledger.Projection.Apply", ActivityKind.Internal);
|
||||
if (activity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
activity.SetTag("tenant", record.TenantId);
|
||||
activity.SetTag("chain_id", record.ChainId);
|
||||
activity.SetTag("sequence", record.SequenceNumber);
|
||||
activity.SetTag("event_id", record.EventId);
|
||||
activity.SetTag("event_type", record.EventType);
|
||||
activity.SetTag("policy_version", record.PolicyVersion);
|
||||
activity.SetTag("finding_id", record.FindingId);
|
||||
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Emits structured timeline events for ledger operations.
|
||||
/// Currently materialised as structured logs; can be swapped for event sink later.
|
||||
/// </summary>
|
||||
internal static class LedgerTimeline
|
||||
{
|
||||
private static readonly EventId LedgerAppended = new(6101, "ledger.event.appended");
|
||||
private static readonly EventId ProjectionUpdated = new(6201, "ledger.projection.updated");
|
||||
|
||||
public static void EmitLedgerAppended(ILogger logger, LedgerEventRecord record, string? evidenceBundleRef = null)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var traceId = Activity.Current?.TraceId.ToHexString() ?? string.Empty;
|
||||
|
||||
logger.LogInformation(
|
||||
LedgerAppended,
|
||||
"timeline ledger.event.appended tenant={Tenant} chain={ChainId} seq={Sequence} event={EventId} type={EventType} policy={PolicyVersion} finding={FindingId} trace={TraceId} evidence_ref={EvidenceRef}",
|
||||
record.TenantId,
|
||||
record.ChainId,
|
||||
record.SequenceNumber,
|
||||
record.EventId,
|
||||
record.EventType,
|
||||
record.PolicyVersion,
|
||||
record.FindingId,
|
||||
traceId,
|
||||
evidenceBundleRef ?? record.EvidenceBundleReference ?? string.Empty);
|
||||
}
|
||||
|
||||
public static void EmitProjectionUpdated(
|
||||
ILogger logger,
|
||||
LedgerEventRecord record,
|
||||
string? evaluationStatus,
|
||||
string? evidenceBundleRef = null)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var traceId = Activity.Current?.TraceId.ToHexString() ?? string.Empty;
|
||||
|
||||
logger.LogInformation(
|
||||
ProjectionUpdated,
|
||||
"timeline ledger.projection.updated tenant={Tenant} chain={ChainId} seq={Sequence} event={EventId} policy={PolicyVersion} finding={FindingId} status={Status} trace={TraceId} evidence_ref={EvidenceRef}",
|
||||
record.TenantId,
|
||||
record.ChainId,
|
||||
record.SequenceNumber,
|
||||
record.EventId,
|
||||
record.PolicyVersion,
|
||||
record.FindingId,
|
||||
evaluationStatus ?? string.Empty,
|
||||
traceId,
|
||||
evidenceBundleRef ?? record.EvidenceBundleReference ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,21 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
public async Task<LedgerWriteResult> AppendAsync(LedgerEventDraft draft, CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var activity = LedgerTelemetry.StartLedgerAppend(draft);
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["tenant"] = draft.TenantId,
|
||||
["chainId"] = draft.ChainId,
|
||||
["sequence"] = draft.SequenceNumber,
|
||||
["eventId"] = draft.EventId,
|
||||
["eventType"] = draft.EventType,
|
||||
["policyVersion"] = draft.PolicyVersion
|
||||
});
|
||||
|
||||
var validationErrors = ValidateDraft(draft);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "validation_failed");
|
||||
return LedgerWriteResult.ValidationFailed([.. validationErrors]);
|
||||
}
|
||||
|
||||
@@ -45,6 +56,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(draft.CanonicalEnvelope);
|
||||
if (!string.Equals(existing.CanonicalJson, canonicalJson, StringComparison.Ordinal))
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "event_id_conflict");
|
||||
return LedgerWriteResult.Conflict(
|
||||
"event_id_conflict",
|
||||
$"Event '{draft.EventId}' already exists with a different payload.");
|
||||
@@ -58,6 +70,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
var expectedSequence = chainHead is null ? 1 : chainHead.SequenceNumber + 1;
|
||||
if (draft.SequenceNumber != expectedSequence)
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "sequence_mismatch");
|
||||
return LedgerWriteResult.Conflict(
|
||||
"sequence_mismatch",
|
||||
$"Sequence number '{draft.SequenceNumber}' does not match expected '{expectedSequence}'.");
|
||||
@@ -66,6 +79,7 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash;
|
||||
if (draft.ProvidedPreviousHash is not null && !string.Equals(draft.ProvidedPreviousHash, previousHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LedgerTelemetry.MarkError(activity, "previous_hash_mismatch");
|
||||
return LedgerWriteResult.Conflict(
|
||||
"previous_hash_mismatch",
|
||||
$"Provided previous hash '{draft.ProvidedPreviousHash}' does not match chain head hash '{previousHash}'.");
|
||||
@@ -93,7 +107,8 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
hashResult.EventHash,
|
||||
previousHash,
|
||||
hashResult.MerkleLeafHash,
|
||||
hashResult.CanonicalJson);
|
||||
hashResult.CanonicalJson,
|
||||
draft.EvidenceBundleReference);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -102,10 +117,29 @@ public sealed class LedgerEventWriteService : ILedgerEventWriteService
|
||||
|
||||
stopwatch.Stop();
|
||||
LedgerMetrics.RecordWriteSuccess(stopwatch.Elapsed, draft.TenantId, draft.EventType, DetermineSource(draft));
|
||||
LedgerTelemetry.MarkAppendOutcome(activity, record, stopwatch.Elapsed);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Ledger append committed for tenant {Tenant} chain {ChainId} seq {Sequence} event {EventId} ({EventType}) hash {Hash} prev {PrevHash}.",
|
||||
record.TenantId,
|
||||
record.ChainId,
|
||||
record.SequenceNumber,
|
||||
record.EventId,
|
||||
record.EventType,
|
||||
record.EventHash,
|
||||
record.PreviousHash);
|
||||
LedgerTimeline.EmitLedgerAppended(_logger, record, evidenceBundleRef: null);
|
||||
}
|
||||
catch (Exception ex) when (IsDuplicateKeyException(ex))
|
||||
{
|
||||
_logger.LogWarning(ex, "Ledger append detected concurrent duplicate for {EventId}", draft.EventId);
|
||||
LedgerTelemetry.MarkError(activity, "duplicate_event");
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Ledger append detected concurrent duplicate for tenant {Tenant} chain {ChainId} seq {Sequence} event {EventId}.",
|
||||
draft.TenantId,
|
||||
draft.ChainId,
|
||||
draft.SequenceNumber,
|
||||
draft.EventId);
|
||||
var persisted = await _repository.GetByEventIdAsync(draft.TenantId, draft.EventId, cancellationToken).ConfigureAwait(false);
|
||||
if (persisted is null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- LEDGER-OBS-53-001: persist evidence bundle references alongside ledger entries.
|
||||
|
||||
ALTER TABLE ledger_events
|
||||
ADD COLUMN evidence_bundle_ref text NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ledger_events_finding_evidence_ref
|
||||
ON ledger_events (tenant_id, finding_id, recorded_at DESC)
|
||||
WHERE evidence_bundle_ref IS NOT NULL;
|
||||
Reference in New Issue
Block a user