using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using StellaOps.Signals.Models; using StellaOps.Signals.Options; namespace StellaOps.Signals.Services; internal sealed class ReachabilityFactEventBuilder { private readonly SignalsOptions options; private readonly TimeProvider timeProvider; public ReachabilityFactEventBuilder(SignalsOptions options, TimeProvider timeProvider) { this.options = options ?? throw new ArgumentNullException(nameof(options)); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } public ReachabilityFactUpdatedEnvelope Build(ReachabilityFactDocument fact) { ArgumentNullException.ThrowIfNull(fact); var summary = BuildSummary(fact); var digest = ResolveDigest(fact); var factVersion = ResolveFactVersion(fact); return new ReachabilityFactUpdatedEnvelope( Topic: ResolveTopic(), EventId: Guid.NewGuid().ToString("n"), Version: "signals.fact.updated@v1", EmittedAtUtc: timeProvider.GetUtcNow(), Tenant: ResolveTenant(fact), SubjectKey: fact.SubjectKey, CallgraphId: string.IsNullOrWhiteSpace(fact.CallgraphId) ? null : fact.CallgraphId, FactKind: "reachability", FactVersion: factVersion, Digest: digest, ContentType: "application/json", Producer: new EventProducerMetadata( Service: options.Events.Producer, Pipeline: options.Events.Pipeline, Release: options.Events.Release ?? typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown"), Trace: ResolveTrace(fact), Summary: summary); } private ReachabilityFactUpdatedEvent BuildSummary(ReachabilityFactDocument fact) { var (reachable, unreachable) = CountStates(fact); var runtimeFactsCount = fact.RuntimeFacts?.Count ?? 0; var avgConfidence = fact.States.Count > 0 ? fact.States.Average(s => s.Confidence) : 0; var uncertaintyStates = fact.Uncertainty?.States ?? new List(); var uncertaintyCodes = uncertaintyStates .Where(s => s is not null && !string.IsNullOrWhiteSpace(s.Code)) .Select(s => s.Code.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(s => s, StringComparer.Ordinal) .ToArray(); var avgEntropy = uncertaintyStates.Count > 0 ? uncertaintyStates.Average(s => s.Entropy) : 0; var maxEntropy = uncertaintyStates.Count > 0 ? uncertaintyStates.Max(s => s.Entropy) : 0; var topBucket = fact.States.Count > 0 ? fact.States .GroupBy(s => s.Bucket, StringComparer.OrdinalIgnoreCase) .OrderByDescending(g => g.Count()) .ThenByDescending(g => g.Average(s => s.Weight)) .First() : null; return new ReachabilityFactUpdatedEvent( Version: "signals.fact.updated@v1", SubjectKey: fact.SubjectKey, CallgraphId: string.IsNullOrWhiteSpace(fact.CallgraphId) ? null : fact.CallgraphId, OccurredAtUtc: timeProvider.GetUtcNow(), ReachableCount: reachable, UnreachableCount: unreachable, RuntimeFactsCount: runtimeFactsCount, Bucket: topBucket?.Key ?? "unknown", Weight: topBucket?.Average(s => s.Weight) ?? 0, StateCount: fact.States.Count, FactScore: fact.Score, RiskScore: fact.RiskScore, UnknownsCount: fact.UnknownsCount, UnknownsPressure: fact.UnknownsPressure, UncertaintyCount: uncertaintyStates.Count, MaxEntropy: maxEntropy, AverageEntropy: avgEntropy, AverageConfidence: avgConfidence, ComputedAtUtc: fact.ComputedAt, Targets: fact.States.Select(s => s.Target).ToArray(), UncertaintyCodes: uncertaintyCodes); } private static (int reachable, int unreachable) CountStates(ReachabilityFactDocument fact) { if (fact.States is null || fact.States.Count == 0) { return (0, 0); } var reachable = fact.States.Count(state => state.Reachable); var unreachable = fact.States.Count - reachable; return (reachable, unreachable); } private string ResolveTopic() { if (!string.IsNullOrWhiteSpace(options.Events.Stream)) { return options.Events.Stream; } if (!string.IsNullOrWhiteSpace(options.AirGap.EventTopic)) { return options.AirGap.EventTopic!; } return "signals.fact.updated.v1"; } private string ResolveTenant(ReachabilityFactDocument fact) { if (fact.Metadata is not null) { if (fact.Metadata.TryGetValue("tenant", out var tenant) && !string.IsNullOrWhiteSpace(tenant)) { return tenant!; } if (fact.Metadata.TryGetValue("subject.tenant", out var subjectTenant) && !string.IsNullOrWhiteSpace(subjectTenant)) { return subjectTenant!; } } return options.Events.DefaultTenant; } private static EventTraceMetadata ResolveTrace(ReachabilityFactDocument fact) { var metadata = fact.Metadata; string? traceId = null; string? spanId = null; if (metadata is not null) { metadata.TryGetValue("trace_id", out traceId); metadata.TryGetValue("span_id", out spanId); if (string.IsNullOrWhiteSpace(traceId) && metadata.TryGetValue("trace.id", out var dottedTrace)) { traceId = dottedTrace; } if (string.IsNullOrWhiteSpace(spanId) && metadata.TryGetValue("trace.parent_span", out var dottedSpan)) { spanId = dottedSpan; } } return new EventTraceMetadata(traceId, spanId); } private static int ResolveFactVersion(ReachabilityFactDocument fact) { if (fact.Metadata is not null && fact.Metadata.TryGetValue("fact.version", out var versionValue) && int.TryParse(versionValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) { return parsed; } return 1; } private static string ResolveDigest(ReachabilityFactDocument fact) { if (fact.Metadata is not null && fact.Metadata.TryGetValue("fact.digest", out var digest) && !string.IsNullOrWhiteSpace(digest)) { return digest!; } return ReachabilityFactDigestCalculator.Compute(fact); } } public sealed record ReachabilityFactUpdatedEnvelope( [property: JsonPropertyName("topic")] string Topic, [property: JsonPropertyName("event_id")] string EventId, [property: JsonPropertyName("version")] string Version, [property: JsonPropertyName("emitted_at")] DateTimeOffset EmittedAtUtc, [property: JsonPropertyName("tenant")] string Tenant, [property: JsonPropertyName("subject_key")] string SubjectKey, [property: JsonPropertyName("callgraph_id")] string? CallgraphId, [property: JsonPropertyName("fact_kind")] string FactKind, [property: JsonPropertyName("fact_version")] int FactVersion, [property: JsonPropertyName("digest")] string Digest, [property: JsonPropertyName("content_type")] string ContentType, [property: JsonPropertyName("producer")] EventProducerMetadata Producer, [property: JsonPropertyName("trace")] EventTraceMetadata Trace, [property: JsonPropertyName("summary")] ReachabilityFactUpdatedEvent Summary); public sealed record EventProducerMetadata( [property: JsonPropertyName("service")] string Service, [property: JsonPropertyName("pipeline")] string Pipeline, [property: JsonPropertyName("release")] string? Release); public sealed record EventTraceMetadata( [property: JsonPropertyName("trace_id")] string? TraceId, [property: JsonPropertyName("span_id")] string? SpanId);