// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Eventing.Internal;
using StellaOps.Eventing.Models;
using StellaOps.Eventing.Signing;
using StellaOps.Eventing.Storage;
using StellaOps.HybridLogicalClock;
using System.Diagnostics;
using System.Text.Json;
namespace StellaOps.Eventing;
///
/// Implementation of for emitting timeline events.
///
public sealed class TimelineEventEmitter : ITimelineEventEmitter
{
private readonly IHybridLogicalClock _hlc;
private readonly TimeProvider _timeProvider;
private readonly ITimelineEventStore _eventStore;
private readonly IEventSigner? _eventSigner;
private readonly IOptions _options;
private readonly ILogger _logger;
private readonly EngineVersionRef _engineVersion;
///
/// Initializes a new instance of the class.
///
public TimelineEventEmitter(
IHybridLogicalClock hlc,
TimeProvider timeProvider,
ITimelineEventStore eventStore,
IOptions options,
ILogger logger,
IEventSigner? eventSigner = null)
{
_hlc = hlc ?? throw new ArgumentNullException(nameof(hlc));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_eventStore = eventStore ?? throw new ArgumentNullException(nameof(eventStore));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_eventSigner = eventSigner;
_engineVersion = options.Value.EngineVersion ?? EngineVersionRef.FromEntryAssembly();
}
///
public async Task EmitAsync(
string correlationId,
string kind,
TPayload payload,
CancellationToken cancellationToken = default) where TPayload : notnull
{
ArgumentException.ThrowIfNullOrWhiteSpace(correlationId);
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
ArgumentNullException.ThrowIfNull(payload);
var timelineEvent = CreateEvent(correlationId, kind, payload);
await _eventStore.AppendAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Emitted timeline event {EventId} for {CorrelationId} [{Kind}]",
timelineEvent.EventId,
correlationId,
kind);
return timelineEvent;
}
///
public async Task> EmitBatchAsync(
IEnumerable events,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(events);
var timelineEvents = events
.Select(e => CreateEvent(e.CorrelationId, e.Kind, e.Payload))
.ToList();
if (timelineEvents.Count == 0)
{
return Array.Empty();
}
await _eventStore.AppendBatchAsync(timelineEvents, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Emitted batch of {Count} timeline events", timelineEvents.Count);
return timelineEvents;
}
private TimelineEvent CreateEvent(
string correlationId,
string kind,
TPayload payload) where TPayload : notnull
{
var tHlc = _hlc.Tick();
var tsWall = _timeProvider.GetUtcNow();
var service = _options.Value.ServiceName;
// Canonicalize payload using RFC 8785
var canonicalPayload = CanonicalizePayload(payload);
var payloadDigest = EventIdGenerator.ComputePayloadDigest(canonicalPayload);
// Generate deterministic event ID
var eventId = EventIdGenerator.Generate(correlationId, tHlc, service, kind);
// Capture trace context if available
var traceParent = Activity.Current?.Id;
var timelineEvent = new TimelineEvent
{
EventId = eventId,
THlc = tHlc,
TsWall = tsWall,
Service = service,
TraceParent = traceParent,
CorrelationId = correlationId,
Kind = kind,
Payload = canonicalPayload,
PayloadDigest = payloadDigest,
EngineVersion = _engineVersion,
SchemaVersion = 1
};
// Sign if signer is available and signing is enabled
if (_eventSigner is not null && _options.Value.SignEvents)
{
var signature = _eventSigner.Sign(timelineEvent);
return timelineEvent with { DsseSig = signature };
}
return timelineEvent;
}
private static string CanonicalizePayload(TPayload payload)
{
// Use RFC 8785 canonicalization
// For now, use standard JSON serialization with sorted keys
// In production, this should use StellaOps.Canonical.Json
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
return JsonSerializer.Serialize(payload, options);
}
}