feat: Add initial implementation of Vulnerability Resolver Jobs
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:
master
2025-11-18 07:52:15 +02:00
parent e69b57d467
commit 8355e2ff75
299 changed files with 13293 additions and 2444 deletions

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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()
{
}
}
}

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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)
{

View File

@@ -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;