Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Runtime/RuntimeStaticMerger.cs

348 lines
12 KiB
C#

using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core;
using StellaOps.Scanner.Reachability.Slices;
namespace StellaOps.Scanner.Reachability.Runtime;
/// <summary>
/// Configuration for runtime-static graph merging.
/// </summary>
public sealed record RuntimeStaticMergeOptions
{
/// <summary>
/// Confidence boost for edges observed at runtime. Default: 1.0 (max).
/// </summary>
public double ObservedConfidenceBoost { get; init; } = 1.0;
/// <summary>
/// Base confidence for runtime-only edges (not in static graph). Default: 0.9.
/// </summary>
public double RuntimeOnlyConfidence { get; init; } = 0.9;
/// <summary>
/// Minimum observation count to include a runtime-only edge. Default: 1.
/// </summary>
public int MinObservationCount { get; init; } = 1;
/// <summary>
/// Maximum age of observations to consider fresh. Default: 7 days.
/// </summary>
public TimeSpan FreshnessWindow { get; init; } = TimeSpan.FromDays(7);
/// <summary>
/// Whether to add edges from runtime that don't exist in static graph.
/// </summary>
public bool AddRuntimeOnlyEdges { get; init; } = true;
}
/// <summary>
/// Result of merging runtime traces with static call graph.
/// </summary>
public sealed record RuntimeStaticMergeResult
{
/// <summary>
/// Merged graph with runtime annotations.
/// </summary>
public required CallGraph MergedGraph { get; init; }
/// <summary>
/// Statistics about the merge operation.
/// </summary>
public required MergeStatistics Statistics { get; init; }
/// <summary>
/// Edges that were observed at runtime.
/// </summary>
public ImmutableArray<ObservedEdge> ObservedEdges { get; init; } = ImmutableArray<ObservedEdge>.Empty;
/// <summary>
/// Edges added from runtime that weren't in static graph.
/// </summary>
public ImmutableArray<RuntimeOnlyEdge> RuntimeOnlyEdges { get; init; } = ImmutableArray<RuntimeOnlyEdge>.Empty;
}
/// <summary>
/// Statistics from the merge operation.
/// </summary>
public sealed record MergeStatistics
{
public int StaticEdgeCount { get; init; }
public int RuntimeEventCount { get; init; }
public int MatchedEdgeCount { get; init; }
public int RuntimeOnlyEdgeCount { get; init; }
public int UnmatchedStaticEdgeCount { get; init; }
public double CoverageRatio => StaticEdgeCount > 0
? (double)MatchedEdgeCount / StaticEdgeCount
: 0.0;
}
/// <summary>
/// An edge that was observed at runtime.
/// </summary>
public sealed record ObservedEdge
{
public required string From { get; init; }
public required string To { get; init; }
public required DateTimeOffset FirstObserved { get; init; }
public required DateTimeOffset LastObserved { get; init; }
public required int ObservationCount { get; init; }
public string? TraceDigest { get; init; }
}
/// <summary>
/// An edge that only exists in runtime observations (dynamic dispatch, etc).
/// </summary>
public sealed record RuntimeOnlyEdge
{
public required string From { get; init; }
public required string To { get; init; }
public required DateTimeOffset FirstObserved { get; init; }
public required DateTimeOffset LastObserved { get; init; }
public required int ObservationCount { get; init; }
public required string Origin { get; init; } // "runtime", "dynamic_dispatch", etc.
public string? TraceDigest { get; init; }
}
/// <summary>
/// Represents a runtime call event from eBPF/ETW collectors.
/// </summary>
public sealed record RuntimeCallEvent
{
public required ulong Timestamp { get; init; }
public required uint Pid { get; init; }
public required uint Tid { get; init; }
public required string CallerSymbol { get; init; }
public required string CalleeSymbol { get; init; }
public required string BinaryPath { get; init; }
public string? TraceDigest { get; init; }
}
/// <summary>
/// Merges runtime trace observations with static call graphs.
/// </summary>
public sealed class RuntimeStaticMerger
{
private readonly RuntimeStaticMergeOptions _options;
private readonly ILogger<RuntimeStaticMerger> _logger;
private readonly TimeProvider _timeProvider;
public RuntimeStaticMerger(
RuntimeStaticMergeOptions? options = null,
ILogger<RuntimeStaticMerger>? logger = null,
TimeProvider? timeProvider = null)
{
_options = options ?? new RuntimeStaticMergeOptions();
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<RuntimeStaticMerger>.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Merge runtime events into a static call graph.
/// </summary>
public RuntimeStaticMergeResult Merge(
CallGraph staticGraph,
IEnumerable<RuntimeCallEvent> runtimeEvents)
{
ArgumentNullException.ThrowIfNull(staticGraph);
ArgumentNullException.ThrowIfNull(runtimeEvents);
var now = _timeProvider.GetUtcNow();
var freshnessThreshold = now - _options.FreshnessWindow;
// Index static edges for fast lookup
var staticEdgeIndex = BuildStaticEdgeIndex(staticGraph);
// Aggregate runtime events by edge
var runtimeEdgeAggregates = AggregateRuntimeEvents(runtimeEvents);
var observedEdges = new List<ObservedEdge>();
var runtimeOnlyEdges = new List<RuntimeOnlyEdge>();
var modifiedEdges = new List<CallEdge>();
var matchedEdgeKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var (edgeKey, aggregate) in runtimeEdgeAggregates)
{
// Skip stale observations
if (aggregate.LastObserved < freshnessThreshold)
{
continue;
}
// Skip low observation counts
if (aggregate.ObservationCount < _options.MinObservationCount)
{
continue;
}
if (staticEdgeIndex.TryGetValue(edgeKey, out var staticEdge))
{
// Edge exists in static graph - mark as observed
matchedEdgeKeys.Add(edgeKey);
var observedMetadata = new ObservedEdgeMetadata
{
FirstObserved = aggregate.FirstObserved,
LastObserved = aggregate.LastObserved,
ObservationCount = aggregate.ObservationCount,
TraceDigest = aggregate.TraceDigest
};
var boostedEdge = staticEdge with
{
Confidence = _options.ObservedConfidenceBoost,
Observed = observedMetadata
};
modifiedEdges.Add(boostedEdge);
observedEdges.Add(new ObservedEdge
{
From = aggregate.From,
To = aggregate.To,
FirstObserved = aggregate.FirstObserved,
LastObserved = aggregate.LastObserved,
ObservationCount = aggregate.ObservationCount,
TraceDigest = aggregate.TraceDigest
});
}
else if (_options.AddRuntimeOnlyEdges)
{
// Edge only exists in runtime - add it
var runtimeEdge = new CallEdge
{
From = aggregate.From,
To = aggregate.To,
Kind = CallEdgeKind.Dynamic,
Confidence = ComputeRuntimeOnlyConfidence(aggregate),
Evidence = "runtime_observation",
Observed = new ObservedEdgeMetadata
{
FirstObserved = aggregate.FirstObserved,
LastObserved = aggregate.LastObserved,
ObservationCount = aggregate.ObservationCount,
TraceDigest = aggregate.TraceDigest
}
};
modifiedEdges.Add(runtimeEdge);
runtimeOnlyEdges.Add(new RuntimeOnlyEdge
{
From = aggregate.From,
To = aggregate.To,
FirstObserved = aggregate.FirstObserved,
LastObserved = aggregate.LastObserved,
ObservationCount = aggregate.ObservationCount,
Origin = "runtime",
TraceDigest = aggregate.TraceDigest
});
}
}
// Build merged edge list: unmatched static + modified
var mergedEdges = new List<CallEdge>();
foreach (var edge in staticGraph.Edges)
{
var key = BuildEdgeKey(edge.From, edge.To);
if (!matchedEdgeKeys.Contains(key))
{
mergedEdges.Add(edge);
}
}
mergedEdges.AddRange(modifiedEdges);
var mergedGraph = staticGraph with
{
Edges = mergedEdges.ToImmutableArray()
};
var statistics = new MergeStatistics
{
StaticEdgeCount = staticGraph.Edges.Length,
RuntimeEventCount = runtimeEdgeAggregates.Count,
MatchedEdgeCount = matchedEdgeKeys.Count,
RuntimeOnlyEdgeCount = runtimeOnlyEdges.Count,
UnmatchedStaticEdgeCount = staticGraph.Edges.Length - matchedEdgeKeys.Count
};
_logger.LogInformation(
"Merged runtime traces: {Matched}/{Static} edges observed ({Coverage:P1}), {RuntimeOnly} runtime-only edges added",
statistics.MatchedEdgeCount,
statistics.StaticEdgeCount,
statistics.CoverageRatio,
statistics.RuntimeOnlyEdgeCount);
return new RuntimeStaticMergeResult
{
MergedGraph = mergedGraph,
Statistics = statistics,
ObservedEdges = observedEdges.ToImmutableArray(),
RuntimeOnlyEdges = runtimeOnlyEdges.ToImmutableArray()
};
}
private static Dictionary<string, CallEdge> BuildStaticEdgeIndex(CallGraph graph)
{
var index = new Dictionary<string, CallEdge>(StringComparer.Ordinal);
foreach (var edge in graph.Edges)
{
var key = BuildEdgeKey(edge.From, edge.To);
index.TryAdd(key, edge);
}
return index;
}
private static Dictionary<string, RuntimeEdgeAggregate> AggregateRuntimeEvents(
IEnumerable<RuntimeCallEvent> events)
{
var aggregates = new Dictionary<string, RuntimeEdgeAggregate>(StringComparer.Ordinal);
foreach (var evt in events)
{
var key = BuildEdgeKey(evt.CallerSymbol, evt.CalleeSymbol);
if (aggregates.TryGetValue(key, out var existing))
{
aggregates[key] = existing with
{
ObservationCount = existing.ObservationCount + 1,
LastObserved = DateTimeOffset.FromUnixTimeMilliseconds((long)(evt.Timestamp / 1_000_000))
};
}
else
{
var timestamp = DateTimeOffset.FromUnixTimeMilliseconds((long)(evt.Timestamp / 1_000_000));
aggregates[key] = new RuntimeEdgeAggregate
{
From = evt.CallerSymbol,
To = evt.CalleeSymbol,
FirstObserved = timestamp,
LastObserved = timestamp,
ObservationCount = 1,
TraceDigest = evt.TraceDigest
};
}
}
return aggregates;
}
private double ComputeRuntimeOnlyConfidence(RuntimeEdgeAggregate aggregate)
{
// Higher observation count = higher confidence, capped at runtime-only max
var countFactor = Math.Min(1.0, aggregate.ObservationCount / 10.0);
return _options.RuntimeOnlyConfidence * (0.5 + 0.5 * countFactor);
}
private static string BuildEdgeKey(string from, string to) => $"{from}->{to}";
private sealed record RuntimeEdgeAggregate
{
public required string From { get; init; }
public required string To { get; init; }
public required DateTimeOffset FirstObserved { get; init; }
public required DateTimeOffset LastObserved { get; init; }
public required int ObservationCount { get; init; }
public string? TraceDigest { get; init; }
}
}