save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View File

@@ -0,0 +1,59 @@
// <copyright file="IReplayOrchestrator.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.Replay.Anonymization;
using StellaOps.Testing.Temporal;
namespace StellaOps.Testing.Replay;
/// <summary>
/// Orchestrates replay of anonymized traces for testing.
/// </summary>
public interface IReplayOrchestrator
{
/// <summary>
/// Replay an anonymized trace.
/// </summary>
/// <param name="trace">The trace to replay.</param>
/// <param name="timeProvider">Time provider for simulated time.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The replay result.</returns>
Task<ReplayResult> ReplayAsync(
AnonymizedTrace trace,
SimulatedTimeProvider timeProvider,
CancellationToken ct = default);
}
/// <summary>
/// Result of a trace replay.
/// </summary>
/// <param name="Success">Whether replay succeeded.</param>
/// <param name="OutputHash">Hash of replay output.</param>
/// <param name="Duration">Duration of replay.</param>
/// <param name="FailureReason">Reason for failure, if any.</param>
/// <param name="Warnings">Warnings generated during replay.</param>
/// <param name="SpanResults">Results for individual spans.</param>
public sealed record ReplayResult(
bool Success,
string OutputHash,
TimeSpan Duration,
string? FailureReason,
ImmutableArray<string> Warnings,
ImmutableArray<SpanReplayResult> SpanResults);
/// <summary>
/// Result of replaying a single span.
/// </summary>
/// <param name="SpanId">The span identifier.</param>
/// <param name="Success">Whether span replay succeeded.</param>
/// <param name="Duration">Duration of span replay.</param>
/// <param name="DurationDelta">Difference from original duration.</param>
/// <param name="OutputHash">Hash of span output.</param>
public sealed record SpanReplayResult(
string SpanId,
bool Success,
TimeSpan Duration,
TimeSpan DurationDelta,
string OutputHash);

View File

@@ -0,0 +1,126 @@
// <copyright file="ITraceCorpusManager.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using StellaOps.Replay.Anonymization;
namespace StellaOps.Testing.Replay;
/// <summary>
/// Manages corpus of anonymized traces for replay testing.
/// </summary>
public interface ITraceCorpusManager
{
/// <summary>
/// Import anonymized trace into corpus.
/// </summary>
/// <param name="trace">The anonymized trace.</param>
/// <param name="classification">Classification of the trace.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The corpus entry.</returns>
Task<TraceCorpusEntry> ImportAsync(
AnonymizedTrace trace,
TraceClassification classification,
CancellationToken ct = default);
/// <summary>
/// Query traces by classification for test scenarios.
/// </summary>
/// <param name="query">The query parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matching corpus entries.</returns>
IAsyncEnumerable<TraceCorpusEntry> QueryAsync(
TraceQuery query,
CancellationToken ct = default);
/// <summary>
/// Get trace statistics for corpus health.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Corpus statistics.</returns>
Task<TraceCorpusStatistics> GetStatisticsAsync(CancellationToken ct = default);
}
/// <summary>
/// An entry in the trace corpus.
/// </summary>
/// <param name="EntryId">Unique entry identifier.</param>
/// <param name="Trace">The anonymized trace.</param>
/// <param name="Classification">Trace classification.</param>
/// <param name="ImportedAt">When the trace was imported.</param>
/// <param name="ExpectedOutputHash">Expected output hash for determinism verification.</param>
public sealed record TraceCorpusEntry(
string EntryId,
AnonymizedTrace Trace,
TraceClassification Classification,
DateTimeOffset ImportedAt,
string? ExpectedOutputHash);
/// <summary>
/// Classification for a trace.
/// </summary>
/// <param name="Category">Trace category.</param>
/// <param name="Complexity">Trace complexity level.</param>
/// <param name="Tags">Additional tags.</param>
/// <param name="FailureMode">Expected failure mode, if any.</param>
public sealed record TraceClassification(
TraceCategory Category,
TraceComplexity Complexity,
ImmutableArray<string> Tags,
string? FailureMode);
/// <summary>
/// Category of trace.
/// </summary>
public enum TraceCategory
{
Scan,
Attestation,
VexConsensus,
Advisory,
Evidence,
Auth,
MultiModule
}
/// <summary>
/// Complexity level of a trace.
/// </summary>
public enum TraceComplexity
{
Simple,
Medium,
Complex,
EdgeCase
}
/// <summary>
/// Query parameters for trace corpus.
/// </summary>
/// <param name="Category">Filter by category.</param>
/// <param name="MinComplexity">Minimum complexity level.</param>
/// <param name="RequiredTags">Tags that must be present.</param>
/// <param name="FailureMode">Filter by failure mode.</param>
/// <param name="Limit">Maximum results to return.</param>
public sealed record TraceQuery(
TraceCategory? Category = null,
TraceComplexity? MinComplexity = null,
ImmutableArray<string> RequiredTags = default,
string? FailureMode = null,
int Limit = 100);
/// <summary>
/// Statistics about the trace corpus.
/// </summary>
/// <param name="TotalTraces">Total number of traces.</param>
/// <param name="TracesByCategory">Count by category.</param>
/// <param name="TracesByComplexity">Count by complexity.</param>
/// <param name="OldestTrace">Timestamp of oldest trace.</param>
/// <param name="NewestTrace">Timestamp of newest trace.</param>
public sealed record TraceCorpusStatistics(
int TotalTraces,
ImmutableDictionary<TraceCategory, int> TracesByCategory,
ImmutableDictionary<TraceComplexity, int> TracesByComplexity,
DateTimeOffset? OldestTrace,
DateTimeOffset? NewestTrace);

View File

@@ -0,0 +1,187 @@
// <copyright file="ReplayIntegrationTestBase.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_002_TEST_trace_replay_evidence
// Task: TREP-007, TREP-008
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Replay.Anonymization;
using StellaOps.Testing.Temporal;
using Xunit;
namespace StellaOps.Testing.Replay;
/// <summary>
/// Base class for integration tests that replay production traces.
/// </summary>
public abstract class ReplayIntegrationTestBase : IAsyncLifetime
{
/// <summary>
/// Gets the trace corpus manager.
/// </summary>
protected ITraceCorpusManager CorpusManager { get; private set; } = null!;
/// <summary>
/// Gets the replay orchestrator.
/// </summary>
protected IReplayOrchestrator ReplayOrchestrator { get; private set; } = null!;
/// <summary>
/// Gets the simulated time provider.
/// </summary>
protected SimulatedTimeProvider TimeProvider { get; private set; } = null!;
/// <summary>
/// Gets the service provider.
/// </summary>
protected IServiceProvider Services { get; private set; } = null!;
/// <inheritdoc/>
public virtual async ValueTask InitializeAsync()
{
var services = new ServiceCollection();
ConfigureServices(services);
Services = services.BuildServiceProvider();
CorpusManager = Services.GetRequiredService<ITraceCorpusManager>();
ReplayOrchestrator = Services.GetRequiredService<IReplayOrchestrator>();
TimeProvider = Services.GetRequiredService<SimulatedTimeProvider>();
await OnInitializedAsync();
}
/// <summary>
/// Configure services for the test.
/// </summary>
/// <param name="services">The service collection.</param>
protected virtual void ConfigureServices(IServiceCollection services)
{
services.AddReplayTesting();
}
/// <summary>
/// Called after initialization is complete.
/// </summary>
protected virtual Task OnInitializedAsync() => Task.CompletedTask;
/// <summary>
/// Replay a trace and verify behavior matches expected outcome.
/// </summary>
/// <param name="trace">The trace to replay.</param>
/// <param name="expectation">Expected outcome.</param>
/// <returns>The replay result.</returns>
protected async Task<ReplayResult> ReplayAndVerifyAsync(
TraceCorpusEntry trace,
ReplayExpectation expectation)
{
var result = await ReplayOrchestrator.ReplayAsync(
trace.Trace,
TimeProvider);
VerifyExpectation(result, expectation);
return result;
}
/// <summary>
/// Replay all traces matching query and collect results.
/// </summary>
/// <param name="query">Query for traces to replay.</param>
/// <param name="expectationFactory">Factory to create expectations per trace.</param>
/// <returns>Batch replay results.</returns>
protected async Task<ReplayBatchResult> ReplayBatchAsync(
TraceQuery query,
Func<TraceCorpusEntry, ReplayExpectation> expectationFactory)
{
var results = new List<(TraceCorpusEntry Trace, ReplayResult Result, bool Passed)>();
await foreach (var trace in CorpusManager.QueryAsync(query))
{
var expectation = expectationFactory(trace);
var result = await ReplayOrchestrator.ReplayAsync(trace.Trace, TimeProvider);
var passed = VerifyExpectationSafe(result, expectation);
results.Add((trace, result, passed));
}
return new ReplayBatchResult([.. results]);
}
private static void VerifyExpectation(ReplayResult result, ReplayExpectation expectation)
{
if (expectation.ShouldSucceed)
{
result.Success.Should().BeTrue(
$"Replay should succeed: {result.FailureReason}");
}
else
{
result.Success.Should().BeFalse(
$"Replay should fail with: {expectation.ExpectedFailure}");
}
if (expectation.ExpectedOutputHash is not null)
{
result.OutputHash.Should().Be(expectation.ExpectedOutputHash,
"Output hash should match expected");
}
}
private static bool VerifyExpectationSafe(ReplayResult result, ReplayExpectation expectation)
{
try
{
VerifyExpectation(result, expectation);
return true;
}
catch
{
return false;
}
}
/// <inheritdoc/>
public virtual ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>
/// Expected outcome of a trace replay.
/// </summary>
/// <param name="ShouldSucceed">Whether replay should succeed.</param>
/// <param name="ExpectedFailure">Expected failure reason, if should fail.</param>
/// <param name="ExpectedOutputHash">Expected output hash for determinism check.</param>
/// <param name="ExpectedWarnings">Expected warnings.</param>
public sealed record ReplayExpectation(
bool ShouldSucceed,
string? ExpectedFailure = null,
string? ExpectedOutputHash = null,
ImmutableArray<string> ExpectedWarnings = default);
/// <summary>
/// Result of a batch replay operation.
/// </summary>
/// <param name="Results">Individual trace results.</param>
public sealed record ReplayBatchResult(
ImmutableArray<(TraceCorpusEntry Trace, ReplayResult Result, bool Passed)> Results)
{
/// <summary>
/// Gets the total number of traces replayed.
/// </summary>
public int TotalCount => Results.Length;
/// <summary>
/// Gets the number of traces that passed.
/// </summary>
public int PassedCount => Results.Count(r => r.Passed);
/// <summary>
/// Gets the number of traces that failed.
/// </summary>
public int FailedCount => Results.Count(r => !r.Passed);
/// <summary>
/// Gets the pass rate as a decimal (0-1).
/// </summary>
public decimal PassRate => TotalCount > 0 ? (decimal)PassedCount / TotalCount : 0;
}

View File

@@ -0,0 +1,209 @@
// <copyright file="ServiceCollectionExtensions.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Replay.Anonymization;
using StellaOps.Testing.Temporal;
namespace StellaOps.Testing.Replay;
/// <summary>
/// Extension methods for configuring replay testing services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Add replay testing services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddReplayTesting(this IServiceCollection services)
{
services.AddSingleton<SimulatedTimeProvider>(sp =>
new SimulatedTimeProvider(DateTimeOffset.UtcNow));
services.AddSingleton<TimeProvider>(sp =>
sp.GetRequiredService<SimulatedTimeProvider>());
services.AddSingleton<ITraceCorpusManager, InMemoryTraceCorpusManager>();
services.AddSingleton<IReplayOrchestrator, DefaultReplayOrchestrator>();
services.AddSingleton<ITraceAnonymizer, TraceAnonymizer>();
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
return services;
}
}
/// <summary>
/// In-memory implementation of trace corpus manager for testing.
/// </summary>
internal sealed class InMemoryTraceCorpusManager : ITraceCorpusManager
{
private readonly ConcurrentDictionary<string, TraceCorpusEntry> _traces = new();
private readonly TimeProvider _timeProvider;
private int _nextId;
public InMemoryTraceCorpusManager(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public Task<TraceCorpusEntry> ImportAsync(
AnonymizedTrace trace,
TraceClassification classification,
CancellationToken ct = default)
{
var entryId = $"corpus-{Interlocked.Increment(ref _nextId):D6}";
var entry = new TraceCorpusEntry(
EntryId: entryId,
Trace: trace,
Classification: classification,
ImportedAt: _timeProvider.GetUtcNow(),
ExpectedOutputHash: null);
_traces[entryId] = entry;
return Task.FromResult(entry);
}
public async IAsyncEnumerable<TraceCorpusEntry> QueryAsync(
TraceQuery query,
[EnumeratorCancellation] CancellationToken ct = default)
{
var results = _traces.Values.AsEnumerable();
if (query.Category is not null)
{
results = results.Where(e => e.Classification.Category == query.Category);
}
if (query.MinComplexity is not null)
{
results = results.Where(e => e.Classification.Complexity >= query.MinComplexity);
}
if (!query.RequiredTags.IsDefaultOrEmpty)
{
results = results.Where(e =>
query.RequiredTags.All(t => e.Classification.Tags.Contains(t)));
}
if (query.FailureMode is not null)
{
results = results.Where(e => e.Classification.FailureMode == query.FailureMode);
}
var limited = results.Take(query.Limit);
foreach (var entry in limited)
{
ct.ThrowIfCancellationRequested();
await Task.Yield();
yield return entry;
}
}
public Task<TraceCorpusStatistics> GetStatisticsAsync(CancellationToken ct = default)
{
var entries = _traces.Values.ToList();
var byCategory = entries
.GroupBy(e => e.Classification.Category)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var byComplexity = entries
.GroupBy(e => e.Classification.Complexity)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var oldest = entries.Count > 0 ? entries.Min(e => e.ImportedAt) : (DateTimeOffset?)null;
var newest = entries.Count > 0 ? entries.Max(e => e.ImportedAt) : (DateTimeOffset?)null;
return Task.FromResult(new TraceCorpusStatistics(
TotalTraces: entries.Count,
TracesByCategory: byCategory,
TracesByComplexity: byComplexity,
OldestTrace: oldest,
NewestTrace: newest));
}
}
/// <summary>
/// Default implementation of replay orchestrator.
/// </summary>
internal sealed class DefaultReplayOrchestrator : IReplayOrchestrator
{
private readonly ILogger<DefaultReplayOrchestrator> _logger;
public DefaultReplayOrchestrator(ILogger<DefaultReplayOrchestrator> logger)
{
_logger = logger;
}
public Task<ReplayResult> ReplayAsync(
AnonymizedTrace trace,
SimulatedTimeProvider timeProvider,
CancellationToken ct = default)
{
var startTime = timeProvider.GetUtcNow();
var spanResults = new List<SpanReplayResult>();
var warnings = new List<string>();
foreach (var span in trace.Spans)
{
ct.ThrowIfCancellationRequested();
// Simulate span execution
timeProvider.Advance(span.Duration);
var replayDuration = span.Duration; // In simulation, same duration
var delta = TimeSpan.Zero;
spanResults.Add(new SpanReplayResult(
SpanId: span.SpanId,
Success: true,
Duration: replayDuration,
DurationDelta: delta,
OutputHash: ComputeSpanHash(span)));
}
var endTime = timeProvider.GetUtcNow();
var totalDuration = endTime - startTime;
var outputHash = ComputeOutputHash(spanResults);
_logger.LogDebug(
"Replayed trace {TraceId} with {SpanCount} spans in {Duration}",
trace.TraceId, trace.Spans.Length, totalDuration);
return Task.FromResult(new ReplayResult(
Success: true,
OutputHash: outputHash,
Duration: totalDuration,
FailureReason: null,
Warnings: [.. warnings],
SpanResults: [.. spanResults]));
}
private static string ComputeSpanHash(AnonymizedSpan span)
{
var input = $"{span.SpanId}:{span.OperationName}:{span.Duration.Ticks}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant()[..16];
}
private static string ComputeOutputHash(List<SpanReplayResult> results)
{
var input = string.Join("|", results.Select(r => r.OutputHash));
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<UseAppHost>true</UseAppHost>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>true</IsPackable>
<Description>Infrastructure for replay-based integration testing using production traces</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Testing.Replay.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="xunit.v3.assert" PrivateAssets="all" />
<PackageReference Include="xunit.v3.core" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Testing.Temporal\StellaOps.Testing.Temporal.csproj" />
<ProjectReference Include="..\..\..\Replay\__Libraries\StellaOps.Replay.Anonymization\StellaOps.Replay.Anonymization.csproj" />
</ItemGroup>
</Project>