215 lines
8.1 KiB
C#
215 lines
8.1 KiB
C#
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<UncertaintyStateDocument>();
|
|
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);
|