audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
25
src/Timeline/StellaOps.Timeline.WebService/AGENTS.md
Normal file
25
src/Timeline/StellaOps.Timeline.WebService/AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Timeline WebService Module Charter
|
||||
|
||||
## Mission
|
||||
- Provide timeline API endpoints and service orchestration.
|
||||
|
||||
## Responsibilities
|
||||
- Implement HTTP endpoints and request validation.
|
||||
- Orchestrate persistence and timeline workflows.
|
||||
- Enforce deterministic ordering in responses.
|
||||
|
||||
## Required Reading
|
||||
- docs/README.md
|
||||
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
- docs/modules/timeline-indexer/architecture.md
|
||||
- docs/modules/timeline-indexer/README.md
|
||||
|
||||
## Working Agreement
|
||||
- Deterministic ordering and invariant formatting.
|
||||
- Use TimeProvider and IGuidGenerator where timestamps or IDs are created.
|
||||
- Propagate CancellationToken for async operations.
|
||||
|
||||
## Testing Strategy
|
||||
- API tests for validation and error handling.
|
||||
- Determinism checks for response ordering.
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware for authorizing timeline access based on tenant/correlation ownership.
|
||||
/// </summary>
|
||||
public sealed class TimelineAuthorizationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<TimelineAuthorizationMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineAuthorizationMiddleware"/> class.
|
||||
/// </summary>
|
||||
public TimelineAuthorizationMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<TimelineAuthorizationMiddleware> logger)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware.
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
ITimelineAuthorizationService authorizationService)
|
||||
{
|
||||
// Skip authorization for health endpoints
|
||||
if (context.Request.Path.StartsWithSegments("/health"))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract correlation ID from route if present
|
||||
var correlationId = ExtractCorrelationId(context);
|
||||
|
||||
if (!string.IsNullOrEmpty(correlationId))
|
||||
{
|
||||
var user = context.User;
|
||||
var tenantId = user.FindFirstValue("tenant_id");
|
||||
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
_logger.LogWarning("No tenant_id claim found for timeline access");
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsync("Unauthorized: Missing tenant claim");
|
||||
return;
|
||||
}
|
||||
|
||||
var hasAccess = await authorizationService.HasAccessToCorrelationAsync(
|
||||
tenantId,
|
||||
correlationId,
|
||||
context.RequestAborted);
|
||||
|
||||
if (!hasAccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant {TenantId} denied access to correlation {CorrelationId}",
|
||||
tenantId,
|
||||
correlationId);
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync("Forbidden: No access to this correlation");
|
||||
return;
|
||||
}
|
||||
|
||||
// Log audit trail
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} accessed correlation {CorrelationId} via {Method} {Path}",
|
||||
tenantId,
|
||||
correlationId,
|
||||
context.Request.Method,
|
||||
context.Request.Path);
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static string? ExtractCorrelationId(HttpContext context)
|
||||
{
|
||||
// Try to get from route values
|
||||
if (context.Request.RouteValues.TryGetValue("correlationId", out var routeValue))
|
||||
{
|
||||
return routeValue?.ToString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for authorizing timeline access.
|
||||
/// </summary>
|
||||
public interface ITimelineAuthorizationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a tenant has access to a correlation ID.
|
||||
/// </summary>
|
||||
Task<bool> HasAccessToCorrelationAsync(
|
||||
string tenantId,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation that allows all access.
|
||||
/// Production should integrate with Authority service.
|
||||
/// </summary>
|
||||
public sealed class DefaultTimelineAuthorizationService : ITimelineAuthorizationService
|
||||
{
|
||||
private readonly ILogger<DefaultTimelineAuthorizationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultTimelineAuthorizationService"/> class.
|
||||
/// </summary>
|
||||
public DefaultTimelineAuthorizationService(ILogger<DefaultTimelineAuthorizationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> HasAccessToCorrelationAsync(
|
||||
string tenantId,
|
||||
string correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Default: allow all access
|
||||
// Production: check correlation -> tenant mapping in database or Authority
|
||||
_logger.LogDebug(
|
||||
"Authorization check: tenant={TenantId}, correlation={CorrelationId} -> allowed (default)",
|
||||
tenantId,
|
||||
correlationId);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for timeline authorization.
|
||||
/// </summary>
|
||||
public static class TimelineAuthorizationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds timeline authorization middleware to the pipeline.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseTimelineAuthorization(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<TimelineAuthorizationMiddleware>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.Timeline.Core;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Export endpoints for timeline bundles.
|
||||
/// </summary>
|
||||
public static class ExportEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps export endpoints.
|
||||
/// </summary>
|
||||
public static void MapExportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/timeline")
|
||||
.WithTags("Export")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/{correlationId}/export", ExportTimelineAsync)
|
||||
.WithName("ExportTimeline")
|
||||
.WithDescription("Export timeline events as NDJSON bundle with optional DSSE signing");
|
||||
|
||||
group.MapGet("/export/{exportId}", GetExportStatusAsync)
|
||||
.WithName("GetExportStatus")
|
||||
.WithDescription("Get the status of an export operation");
|
||||
|
||||
group.MapGet("/export/{exportId}/download", DownloadExportAsync)
|
||||
.WithName("DownloadExport")
|
||||
.WithDescription("Download the completed export bundle");
|
||||
}
|
||||
|
||||
private static async Task<Results<Accepted<ExportInitiatedResponse>, BadRequest<string>>> ExportTimelineAsync(
|
||||
string correlationId,
|
||||
ExportRequest request,
|
||||
ITimelineQueryService queryService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
return TypedResults.BadRequest("Correlation ID is required");
|
||||
}
|
||||
|
||||
// Validate the correlation exists
|
||||
var result = await queryService.GetByCorrelationIdAsync(correlationId, new TimelineQueryOptions { Limit = 1 }, cancellationToken);
|
||||
if (result.Events.Count == 0)
|
||||
{
|
||||
return TypedResults.BadRequest($"No events found for correlation ID: {correlationId}");
|
||||
}
|
||||
|
||||
// TODO: Queue export job
|
||||
var exportId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/timeline/export/{exportId}",
|
||||
new ExportInitiatedResponse
|
||||
{
|
||||
ExportId = exportId,
|
||||
CorrelationId = correlationId,
|
||||
Format = request.Format,
|
||||
SignBundle = request.SignBundle,
|
||||
Status = "INITIATED",
|
||||
EstimatedEventCount = result.TotalCount
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ExportStatusResponse>, NotFound>> GetExportStatusAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Integrate with export state store
|
||||
await Task.CompletedTask;
|
||||
|
||||
return TypedResults.Ok(new ExportStatusResponse
|
||||
{
|
||||
ExportId = exportId,
|
||||
Status = "COMPLETED",
|
||||
Format = "ndjson",
|
||||
EventCount = 100,
|
||||
FileSizeBytes = 45678,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
CompletedAt = DateTimeOffset.UtcNow.AddSeconds(-30)
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<FileStreamHttpResult, NotFound>> DownloadExportAsync(
|
||||
string exportId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Integrate with export storage
|
||||
await Task.CompletedTask;
|
||||
|
||||
// Return stub for now - real implementation would stream from storage
|
||||
var stubContent = """
|
||||
{"event_id":"abc123","correlation_id":"scan-1","kind":"ENQUEUE"}
|
||||
{"event_id":"def456","correlation_id":"scan-1","kind":"EXECUTE"}
|
||||
""";
|
||||
var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(stubContent));
|
||||
|
||||
return TypedResults.File(
|
||||
stream,
|
||||
contentType: "application/x-ndjson",
|
||||
fileDownloadName: $"timeline-{exportId}.ndjson");
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed record ExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Export format: "ndjson" or "json".
|
||||
/// </summary>
|
||||
public string Format { get; init; } = "ndjson";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to DSSE-sign the bundle.
|
||||
/// </summary>
|
||||
public bool SignBundle { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional HLC range start.
|
||||
/// </summary>
|
||||
public string? FromHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional HLC range end.
|
||||
/// </summary>
|
||||
public string? ToHlc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ExportInitiatedResponse
|
||||
{
|
||||
public required string ExportId { get; init; }
|
||||
public required string CorrelationId { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public bool SignBundle { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public long EstimatedEventCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ExportStatusResponse
|
||||
{
|
||||
public required string ExportId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public long EventCount { get; init; }
|
||||
public long FileSizeBytes { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using StellaOps.Eventing.Storage;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Health check endpoints.
|
||||
/// </summary>
|
||||
public static class HealthEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps health check endpoints.
|
||||
/// </summary>
|
||||
public static void MapHealthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapHealthChecks("/health");
|
||||
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains("ready")
|
||||
});
|
||||
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check for timeline service.
|
||||
/// </summary>
|
||||
public sealed class TimelineHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ITimelineEventStore _eventStore;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimelineHealthCheck"/> class.
|
||||
/// </summary>
|
||||
public TimelineHealthCheck(ITimelineEventStore eventStore)
|
||||
{
|
||||
_eventStore = eventStore;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Simple check - try to count events for a nonexistent correlation
|
||||
// This validates database connectivity
|
||||
await _eventStore.CountByCorrelationIdAsync("__health_check__", cancellationToken);
|
||||
return HealthCheckResult.Healthy("Timeline service is healthy");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Timeline service is unhealthy", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Replay endpoints for deterministic replay of event sequences.
|
||||
/// </summary>
|
||||
public static class ReplayEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps replay endpoints.
|
||||
/// </summary>
|
||||
public static void MapReplayEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/timeline")
|
||||
.WithTags("Replay")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/{correlationId}/replay", InitiateReplayAsync)
|
||||
.WithName("InitiateReplay")
|
||||
.WithDescription("Initiate deterministic replay for a correlation ID");
|
||||
|
||||
group.MapGet("/replay/{replayId}", GetReplayStatusAsync)
|
||||
.WithName("GetReplayStatus")
|
||||
.WithDescription("Get the status of a replay operation");
|
||||
|
||||
group.MapPost("/replay/{replayId}/cancel", CancelReplayAsync)
|
||||
.WithName("CancelReplay")
|
||||
.WithDescription("Cancel an in-progress replay operation");
|
||||
}
|
||||
|
||||
private static async Task<Results<Accepted<ReplayInitiatedResponse>, BadRequest<string>>> InitiateReplayAsync(
|
||||
string correlationId,
|
||||
ReplayRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
return TypedResults.BadRequest("Correlation ID is required");
|
||||
}
|
||||
|
||||
// TODO: Integrate with StellaOps.Replay.Core
|
||||
// For now, return a stub response
|
||||
var replayId = Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
await Task.CompletedTask; // Placeholder for actual implementation
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/timeline/replay/{replayId}",
|
||||
new ReplayInitiatedResponse
|
||||
{
|
||||
ReplayId = replayId,
|
||||
CorrelationId = correlationId,
|
||||
Mode = request.Mode,
|
||||
Status = "INITIATED",
|
||||
EstimatedDurationMs = 5000
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<ReplayStatusResponse>, NotFound>> GetReplayStatusAsync(
|
||||
string replayId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Integrate with replay state store
|
||||
await Task.CompletedTask;
|
||||
|
||||
return TypedResults.Ok(new ReplayStatusResponse
|
||||
{
|
||||
ReplayId = replayId,
|
||||
Status = "IN_PROGRESS",
|
||||
Progress = 0.5,
|
||||
EventsProcessed = 50,
|
||||
TotalEvents = 100,
|
||||
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5)
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok, NotFound>> CancelReplayAsync(
|
||||
string replayId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Integrate with replay cancellation
|
||||
await Task.CompletedTask;
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed record ReplayRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Replay mode: "dry-run" or "verify".
|
||||
/// </summary>
|
||||
public string Mode { get; init; } = "dry-run";
|
||||
|
||||
/// <summary>
|
||||
/// HLC to replay from (optional).
|
||||
/// </summary>
|
||||
public string? FromHlc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// HLC to replay to (optional).
|
||||
/// </summary>
|
||||
public string? ToHlc { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReplayInitiatedResponse
|
||||
{
|
||||
public required string ReplayId { get; init; }
|
||||
public required string CorrelationId { get; init; }
|
||||
public required string Mode { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public long EstimatedDurationMs { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReplayStatusResponse
|
||||
{
|
||||
public required string ReplayId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public double Progress { get; init; }
|
||||
public int EventsProcessed { get; init; }
|
||||
public int TotalEvents { get; init; }
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.HybridLogicalClock;
|
||||
using StellaOps.Timeline.Core;
|
||||
|
||||
namespace StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Timeline query endpoints.
|
||||
/// </summary>
|
||||
public static class TimelineEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps timeline query endpoints.
|
||||
/// </summary>
|
||||
public static void MapTimelineEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/timeline")
|
||||
.WithTags("Timeline")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/{correlationId}", GetTimelineAsync)
|
||||
.WithName("GetTimeline")
|
||||
.WithDescription("Get events for a correlation ID, ordered by HLC timestamp");
|
||||
|
||||
group.MapGet("/{correlationId}/critical-path", GetCriticalPathAsync)
|
||||
.WithName("GetCriticalPath")
|
||||
.WithDescription("Get the critical path (longest latency stages) for a correlation");
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<TimelineResponse>, NotFound>> GetTimelineAsync(
|
||||
string correlationId,
|
||||
ITimelineQueryService queryService,
|
||||
int? limit,
|
||||
int? offset,
|
||||
string? fromHlc,
|
||||
string? toHlc,
|
||||
string? services,
|
||||
string? kinds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new TimelineQueryOptions
|
||||
{
|
||||
Limit = limit ?? 100,
|
||||
Offset = offset ?? 0,
|
||||
FromHlc = !string.IsNullOrEmpty(fromHlc) ? HlcTimestamp.Parse(fromHlc) : null,
|
||||
ToHlc = !string.IsNullOrEmpty(toHlc) ? HlcTimestamp.Parse(toHlc) : null,
|
||||
Services = !string.IsNullOrEmpty(services)
|
||||
? services.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
|
||||
: null,
|
||||
Kinds = !string.IsNullOrEmpty(kinds)
|
||||
? kinds.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
|
||||
: null
|
||||
};
|
||||
|
||||
var result = await queryService.GetByCorrelationIdAsync(correlationId, options, cancellationToken);
|
||||
|
||||
if (result.Events.Count == 0)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new TimelineResponse
|
||||
{
|
||||
CorrelationId = correlationId,
|
||||
Events = result.Events.Select(e => new TimelineEventDto
|
||||
{
|
||||
EventId = e.EventId,
|
||||
THlc = e.THlc.ToSortableString(),
|
||||
TsWall = e.TsWall,
|
||||
Service = e.Service,
|
||||
Kind = e.Kind,
|
||||
Payload = e.Payload,
|
||||
EngineVersion = new EngineVersionDto(
|
||||
e.EngineVersion.EngineName,
|
||||
e.EngineVersion.Version,
|
||||
e.EngineVersion.SourceDigest)
|
||||
}).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
HasMore = result.HasMore,
|
||||
NextCursor = result.NextCursor
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<CriticalPathResponse>, NotFound>> GetCriticalPathAsync(
|
||||
string correlationId,
|
||||
ITimelineQueryService queryService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await queryService.GetCriticalPathAsync(correlationId, cancellationToken);
|
||||
|
||||
if (result.Stages.Count == 0)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new CriticalPathResponse
|
||||
{
|
||||
CorrelationId = result.CorrelationId,
|
||||
TotalDurationMs = result.TotalDuration.TotalMilliseconds,
|
||||
Stages = result.Stages.Select(s => new CriticalPathStageDto
|
||||
{
|
||||
Stage = s.Stage,
|
||||
Service = s.Service,
|
||||
DurationMs = s.Duration.TotalMilliseconds,
|
||||
Percentage = s.Percentage,
|
||||
FromHlc = s.FromHlc.ToSortableString(),
|
||||
ToHlc = s.ToHlc.ToSortableString()
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public sealed record TimelineResponse
|
||||
{
|
||||
public required string CorrelationId { get; init; }
|
||||
public required IReadOnlyList<TimelineEventDto> Events { get; init; }
|
||||
public long TotalCount { get; init; }
|
||||
public bool HasMore { get; init; }
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TimelineEventDto
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
public required string THlc { get; init; }
|
||||
public required DateTimeOffset TsWall { get; init; }
|
||||
public required string Service { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required EngineVersionDto EngineVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record EngineVersionDto(string EngineName, string Version, string SourceDigest);
|
||||
|
||||
public sealed record CriticalPathResponse
|
||||
{
|
||||
public required string CorrelationId { get; init; }
|
||||
public double TotalDurationMs { get; init; }
|
||||
public required IReadOnlyList<CriticalPathStageDto> Stages { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CriticalPathStageDto
|
||||
{
|
||||
public required string Stage { get; init; }
|
||||
public required string Service { get; init; }
|
||||
public double DurationMs { get; init; }
|
||||
public double Percentage { get; init; }
|
||||
public required string FromHlc { get; init; }
|
||||
public required string ToHlc { get; init; }
|
||||
}
|
||||
42
src/Timeline/StellaOps.Timeline.WebService/Program.cs
Normal file
42
src/Timeline/StellaOps.Timeline.WebService/Program.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using StellaOps.Eventing;
|
||||
using StellaOps.Timeline.Core;
|
||||
using StellaOps.Timeline.WebService.Endpoints;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services
|
||||
builder.Services.AddStellaOpsEventing(builder.Configuration);
|
||||
builder.Services.AddTimelineServices(builder.Configuration);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "StellaOps Timeline API",
|
||||
Version = "v1",
|
||||
Description = "Unified event timeline API for querying, replaying, and exporting HLC-ordered events"
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<TimelineHealthCheck>("timeline");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Map endpoints
|
||||
app.MapTimelineEndpoints();
|
||||
app.MapReplayEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapHealthEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Timeline.WebService</RootNamespace>
|
||||
<Description>StellaOps Timeline Service - Unified event timeline API</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
24
src/Timeline/StellaOps.Timeline.WebService/appsettings.json
Normal file
24
src/Timeline/StellaOps.Timeline.WebService/appsettings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"StellaOps": "Debug"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Eventing": {
|
||||
"ServiceName": "Timeline",
|
||||
"UseInMemoryStore": false,
|
||||
"ConnectionString": "",
|
||||
"SignEvents": false,
|
||||
"EnableOutbox": false
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://+:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
536
src/Timeline/StellaOps.Timeline.WebService/openapi.yaml
Normal file
536
src/Timeline/StellaOps.Timeline.WebService/openapi.yaml
Normal file
@@ -0,0 +1,536 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Timeline API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Unified event timeline API for querying, replaying, and exporting HLC-ordered events
|
||||
across all StellaOps services.
|
||||
license:
|
||||
name: AGPL-3.0-or-later
|
||||
url: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
contact:
|
||||
name: StellaOps
|
||||
url: https://stellaops.io
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
description: Timeline API v1
|
||||
|
||||
tags:
|
||||
- name: Timeline
|
||||
description: Query timeline events
|
||||
- name: Replay
|
||||
description: Deterministic replay operations
|
||||
- name: Export
|
||||
description: Export timeline bundles
|
||||
|
||||
paths:
|
||||
/timeline/{correlationId}:
|
||||
get:
|
||||
summary: Get timeline events
|
||||
description: Returns events for a correlation ID, ordered by HLC timestamp
|
||||
operationId: getTimeline
|
||||
tags:
|
||||
- Timeline
|
||||
parameters:
|
||||
- name: correlationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The correlation ID to query
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 100
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
description: Maximum number of events to return
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
minimum: 0
|
||||
description: Number of events to skip
|
||||
- name: services
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of services to filter by
|
||||
- name: kinds
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of event kinds to filter by
|
||||
- name: fromHlc
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Start of HLC range (inclusive)
|
||||
- name: toHlc
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: End of HLC range (inclusive)
|
||||
responses:
|
||||
'200':
|
||||
description: Timeline events
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TimelineResponse'
|
||||
'404':
|
||||
description: No events found for correlation ID
|
||||
|
||||
/timeline/{correlationId}/critical-path:
|
||||
get:
|
||||
summary: Get critical path
|
||||
description: Returns the critical path (longest latency stages) for a correlation
|
||||
operationId: getCriticalPath
|
||||
tags:
|
||||
- Timeline
|
||||
parameters:
|
||||
- name: correlationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The correlation ID to analyze
|
||||
responses:
|
||||
'200':
|
||||
description: Critical path analysis
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CriticalPathResponse'
|
||||
'404':
|
||||
description: No events found for correlation ID
|
||||
|
||||
/timeline/{correlationId}/replay:
|
||||
post:
|
||||
summary: Initiate replay
|
||||
description: Initiate deterministic replay for a correlation ID
|
||||
operationId: initiateReplay
|
||||
tags:
|
||||
- Replay
|
||||
parameters:
|
||||
- name: correlationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The correlation ID to replay
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReplayRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Replay initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReplayInitiatedResponse'
|
||||
'400':
|
||||
description: Invalid request
|
||||
|
||||
/timeline/replay/{replayId}:
|
||||
get:
|
||||
summary: Get replay status
|
||||
description: Get the status of a replay operation
|
||||
operationId: getReplayStatus
|
||||
tags:
|
||||
- Replay
|
||||
parameters:
|
||||
- name: replayId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The replay operation ID
|
||||
responses:
|
||||
'200':
|
||||
description: Replay status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ReplayStatusResponse'
|
||||
'404':
|
||||
description: Replay not found
|
||||
|
||||
/timeline/{correlationId}/export:
|
||||
post:
|
||||
summary: Export timeline
|
||||
description: Export timeline events as NDJSON bundle with optional DSSE signing
|
||||
operationId: exportTimeline
|
||||
tags:
|
||||
- Export
|
||||
parameters:
|
||||
- name: correlationId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The correlation ID to export
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExportRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Export initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExportInitiatedResponse'
|
||||
'400':
|
||||
description: Invalid request or no events found
|
||||
|
||||
/timeline/export/{exportId}:
|
||||
get:
|
||||
summary: Get export status
|
||||
description: Get the status of an export operation
|
||||
operationId: getExportStatus
|
||||
tags:
|
||||
- Export
|
||||
parameters:
|
||||
- name: exportId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The export operation ID
|
||||
responses:
|
||||
'200':
|
||||
description: Export status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExportStatusResponse'
|
||||
'404':
|
||||
description: Export not found
|
||||
|
||||
/timeline/export/{exportId}/download:
|
||||
get:
|
||||
summary: Download export
|
||||
description: Download the completed export bundle
|
||||
operationId: downloadExport
|
||||
tags:
|
||||
- Export
|
||||
parameters:
|
||||
- name: exportId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The export operation ID
|
||||
responses:
|
||||
'200':
|
||||
description: Export bundle
|
||||
content:
|
||||
application/x-ndjson:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'404':
|
||||
description: Export not found or not completed
|
||||
|
||||
components:
|
||||
schemas:
|
||||
TimelineResponse:
|
||||
type: object
|
||||
required:
|
||||
- correlationId
|
||||
- events
|
||||
properties:
|
||||
correlationId:
|
||||
type: string
|
||||
description: The correlation ID queried
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TimelineEvent'
|
||||
totalCount:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Total number of events for this correlation
|
||||
hasMore:
|
||||
type: boolean
|
||||
description: Whether there are more results
|
||||
nextCursor:
|
||||
type: string
|
||||
description: Cursor for next page (HLC of last event)
|
||||
|
||||
TimelineEvent:
|
||||
type: object
|
||||
required:
|
||||
- eventId
|
||||
- tHlc
|
||||
- tsWall
|
||||
- service
|
||||
- kind
|
||||
- payload
|
||||
- engineVersion
|
||||
properties:
|
||||
eventId:
|
||||
type: string
|
||||
description: Deterministic event ID (SHA-256 hash)
|
||||
tHlc:
|
||||
type: string
|
||||
description: HLC timestamp in sortable format
|
||||
tsWall:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Wall-clock time (ISO 8601)
|
||||
service:
|
||||
type: string
|
||||
description: Service that emitted the event
|
||||
kind:
|
||||
type: string
|
||||
description: Event kind (ENQUEUE, EXECUTE, etc.)
|
||||
payload:
|
||||
type: string
|
||||
description: RFC 8785 canonicalized JSON payload
|
||||
engineVersion:
|
||||
$ref: '#/components/schemas/EngineVersion'
|
||||
|
||||
EngineVersion:
|
||||
type: object
|
||||
required:
|
||||
- engineName
|
||||
- version
|
||||
- sourceDigest
|
||||
properties:
|
||||
engineName:
|
||||
type: string
|
||||
description: Engine/service name
|
||||
version:
|
||||
type: string
|
||||
description: Engine version
|
||||
sourceDigest:
|
||||
type: string
|
||||
description: Source/assembly digest
|
||||
|
||||
CriticalPathResponse:
|
||||
type: object
|
||||
required:
|
||||
- correlationId
|
||||
- stages
|
||||
properties:
|
||||
correlationId:
|
||||
type: string
|
||||
description: The correlation ID analyzed
|
||||
totalDurationMs:
|
||||
type: number
|
||||
format: double
|
||||
description: Total duration in milliseconds
|
||||
stages:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CriticalPathStage'
|
||||
|
||||
CriticalPathStage:
|
||||
type: object
|
||||
required:
|
||||
- stage
|
||||
- service
|
||||
- fromHlc
|
||||
- toHlc
|
||||
properties:
|
||||
stage:
|
||||
type: string
|
||||
description: Stage label (e.g., "ENQUEUE -> EXECUTE")
|
||||
service:
|
||||
type: string
|
||||
description: Service where stage occurred
|
||||
durationMs:
|
||||
type: number
|
||||
format: double
|
||||
description: Duration in milliseconds
|
||||
percentage:
|
||||
type: number
|
||||
format: double
|
||||
description: Percentage of total duration
|
||||
fromHlc:
|
||||
type: string
|
||||
description: HLC at start of stage
|
||||
toHlc:
|
||||
type: string
|
||||
description: HLC at end of stage
|
||||
|
||||
ReplayRequest:
|
||||
type: object
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum:
|
||||
- dry-run
|
||||
- verify
|
||||
default: dry-run
|
||||
description: Replay mode
|
||||
fromHlc:
|
||||
type: string
|
||||
description: HLC to replay from (optional)
|
||||
toHlc:
|
||||
type: string
|
||||
description: HLC to replay to (optional)
|
||||
|
||||
ReplayInitiatedResponse:
|
||||
type: object
|
||||
required:
|
||||
- replayId
|
||||
- correlationId
|
||||
- mode
|
||||
- status
|
||||
properties:
|
||||
replayId:
|
||||
type: string
|
||||
description: Unique replay operation ID
|
||||
correlationId:
|
||||
type: string
|
||||
description: The correlation ID being replayed
|
||||
mode:
|
||||
type: string
|
||||
description: Replay mode
|
||||
status:
|
||||
type: string
|
||||
description: Initial status (INITIATED)
|
||||
estimatedDurationMs:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Estimated duration in milliseconds
|
||||
|
||||
ReplayStatusResponse:
|
||||
type: object
|
||||
required:
|
||||
- replayId
|
||||
- status
|
||||
properties:
|
||||
replayId:
|
||||
type: string
|
||||
description: Unique replay operation ID
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- INITIATED
|
||||
- IN_PROGRESS
|
||||
- COMPLETED
|
||||
- FAILED
|
||||
description: Current status
|
||||
progress:
|
||||
type: number
|
||||
format: double
|
||||
description: Progress (0.0 to 1.0)
|
||||
eventsProcessed:
|
||||
type: integer
|
||||
description: Number of events processed
|
||||
totalEvents:
|
||||
type: integer
|
||||
description: Total number of events
|
||||
startedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Start time
|
||||
completedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Completion time (if completed)
|
||||
error:
|
||||
type: string
|
||||
description: Error message (if failed)
|
||||
|
||||
ExportRequest:
|
||||
type: object
|
||||
properties:
|
||||
format:
|
||||
type: string
|
||||
enum:
|
||||
- ndjson
|
||||
- json
|
||||
default: ndjson
|
||||
description: Export format
|
||||
signBundle:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Whether to DSSE-sign the bundle
|
||||
fromHlc:
|
||||
type: string
|
||||
description: HLC range start (optional)
|
||||
toHlc:
|
||||
type: string
|
||||
description: HLC range end (optional)
|
||||
|
||||
ExportInitiatedResponse:
|
||||
type: object
|
||||
required:
|
||||
- exportId
|
||||
- correlationId
|
||||
- format
|
||||
- status
|
||||
properties:
|
||||
exportId:
|
||||
type: string
|
||||
description: Unique export operation ID
|
||||
correlationId:
|
||||
type: string
|
||||
description: The correlation ID being exported
|
||||
format:
|
||||
type: string
|
||||
description: Export format
|
||||
signBundle:
|
||||
type: boolean
|
||||
description: Whether bundle is signed
|
||||
status:
|
||||
type: string
|
||||
description: Initial status (INITIATED)
|
||||
estimatedEventCount:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Estimated number of events
|
||||
|
||||
ExportStatusResponse:
|
||||
type: object
|
||||
required:
|
||||
- exportId
|
||||
- status
|
||||
- format
|
||||
properties:
|
||||
exportId:
|
||||
type: string
|
||||
description: Unique export operation ID
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- INITIATED
|
||||
- IN_PROGRESS
|
||||
- COMPLETED
|
||||
- FAILED
|
||||
description: Current status
|
||||
format:
|
||||
type: string
|
||||
description: Export format
|
||||
eventCount:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of events exported
|
||||
fileSizeBytes:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Size of export file
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Creation time
|
||||
completedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Completion time (if completed)
|
||||
error:
|
||||
type: string
|
||||
description: Error message (if failed)
|
||||
Reference in New Issue
Block a user