using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; namespace StellaOps.Policy.Engine.Telemetry; /// /// Provides structured timeline events for policy evaluation and decision flows. /// Events are emitted as structured logs with correlation to traces. /// public sealed class PolicyTimelineEvents { private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public PolicyTimelineEvents(ILogger logger, TimeProvider timeProvider) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } #region Evaluation Flow Events /// /// Emits an event when a policy evaluation run starts. /// public void EmitRunStarted(string runId, string tenant, string policyId, string policyVersion, string mode) { var evt = new TimelineEvent { EventType = TimelineEventType.RunStarted, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, PolicyVersion = policyVersion, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["mode"] = mode, }, }; LogTimelineEvent(evt); } /// /// Emits an event when a policy evaluation run completes. /// public void EmitRunCompleted( string runId, string tenant, string policyId, string outcome, double durationSeconds, int findingsCount, string? determinismHash = null) { var evt = new TimelineEvent { EventType = TimelineEventType.RunCompleted, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["outcome"] = outcome, ["duration_seconds"] = durationSeconds, ["findings_count"] = findingsCount, ["determinism_hash"] = determinismHash, }, }; LogTimelineEvent(evt); } /// /// Emits an event when a batch selection phase starts. /// public void EmitSelectionStarted(string runId, string tenant, string policyId, int batchNumber) { var evt = new TimelineEvent { EventType = TimelineEventType.SelectionStarted, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["batch_number"] = batchNumber, }, }; LogTimelineEvent(evt); } /// /// Emits an event when a batch selection phase completes. /// public void EmitSelectionCompleted( string runId, string tenant, string policyId, int batchNumber, int tupleCount, double durationSeconds) { var evt = new TimelineEvent { EventType = TimelineEventType.SelectionCompleted, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["batch_number"] = batchNumber, ["tuple_count"] = tupleCount, ["duration_seconds"] = durationSeconds, }, }; LogTimelineEvent(evt); } /// /// Emits an event when batch evaluation starts. /// public void EmitEvaluationStarted(string runId, string tenant, string policyId, int batchNumber, int tupleCount) { var evt = new TimelineEvent { EventType = TimelineEventType.EvaluationStarted, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["batch_number"] = batchNumber, ["tuple_count"] = tupleCount, }, }; LogTimelineEvent(evt); } /// /// Emits an event when batch evaluation completes. /// public void EmitEvaluationCompleted( string runId, string tenant, string policyId, int batchNumber, int rulesEvaluated, int rulesFired, double durationSeconds) { var evt = new TimelineEvent { EventType = TimelineEventType.EvaluationCompleted, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["batch_number"] = batchNumber, ["rules_evaluated"] = rulesEvaluated, ["rules_fired"] = rulesFired, ["duration_seconds"] = durationSeconds, }, }; LogTimelineEvent(evt); } #endregion #region Decision Flow Events /// /// Emits an event when a rule matches during evaluation. /// public void EmitRuleMatched( string runId, string tenant, string policyId, string ruleId, string findingKey, string? severity = null) { var evt = new TimelineEvent { EventType = TimelineEventType.RuleMatched, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["rule_id"] = ruleId, ["finding_key"] = findingKey, ["severity"] = severity, }, }; LogTimelineEvent(evt); } /// /// Emits an event when a VEX override is applied. /// public void EmitVexOverrideApplied( string runId, string tenant, string policyId, string findingKey, string vendor, string status, string? justification = null) { var evt = new TimelineEvent { EventType = TimelineEventType.VexOverrideApplied, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["finding_key"] = findingKey, ["vendor"] = vendor, ["status"] = status, ["justification"] = justification, }, }; LogTimelineEvent(evt); } /// /// Emits an event when a final verdict is determined for a finding. /// public void EmitVerdictDetermined( string runId, string tenant, string policyId, string findingKey, string verdict, string severity, string? reachabilityState = null, IReadOnlyList? contributingRules = null) { var evt = new TimelineEvent { EventType = TimelineEventType.VerdictDetermined, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["finding_key"] = findingKey, ["verdict"] = verdict, ["severity"] = severity, ["reachability_state"] = reachabilityState, ["contributing_rules"] = contributingRules, }, }; LogTimelineEvent(evt); } /// /// Emits an event when materialization of findings starts. /// public void EmitMaterializationStarted(string runId, string tenant, string policyId, int findingsCount) { var evt = new TimelineEvent { EventType = TimelineEventType.MaterializationStarted, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["findings_count"] = findingsCount, }, }; LogTimelineEvent(evt); } /// /// Emits an event when materialization of findings completes. /// public void EmitMaterializationCompleted( string runId, string tenant, string policyId, int findingsWritten, int findingsUpdated, double durationSeconds) { var evt = new TimelineEvent { EventType = TimelineEventType.MaterializationCompleted, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["findings_written"] = findingsWritten, ["findings_updated"] = findingsUpdated, ["duration_seconds"] = durationSeconds, }, }; LogTimelineEvent(evt); } #endregion #region Error Events /// /// Emits an event when an error occurs during evaluation. /// public void EmitError( string runId, string tenant, string policyId, string errorCode, string errorMessage, string? phase = null) { var evt = new TimelineEvent { EventType = TimelineEventType.Error, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["error_code"] = errorCode, ["error_message"] = errorMessage, ["phase"] = phase, }, }; LogTimelineEvent(evt, LogLevel.Error); } /// /// Emits an event when a determinism violation is detected. /// public void EmitDeterminismViolation( string runId, string tenant, string policyId, string violationType, string details) { var evt = new TimelineEvent { EventType = TimelineEventType.DeterminismViolation, Timestamp = _timeProvider.GetUtcNow(), RunId = runId, Tenant = tenant, PolicyId = policyId, TraceId = Activity.Current?.TraceId.ToString(), SpanId = Activity.Current?.SpanId.ToString(), Data = new Dictionary { ["violation_type"] = violationType, ["details"] = details, }, }; LogTimelineEvent(evt, LogLevel.Warning); } #endregion private void LogTimelineEvent(TimelineEvent evt, LogLevel level = LogLevel.Information) { _logger.Log( level, "PolicyTimeline: {EventType} | run={RunId} tenant={Tenant} policy={PolicyId} trace={TraceId} span={SpanId} data={Data}", evt.EventType, evt.RunId, evt.Tenant, evt.PolicyId, evt.TraceId, evt.SpanId, JsonSerializer.Serialize(evt.Data, TimelineEventJsonContext.Default.DictionaryStringObject)); } } /// /// Types of timeline events emitted during policy evaluation. /// public enum TimelineEventType { RunStarted, RunCompleted, SelectionStarted, SelectionCompleted, EvaluationStarted, EvaluationCompleted, RuleMatched, VexOverrideApplied, VerdictDetermined, MaterializationStarted, MaterializationCompleted, Error, DeterminismViolation, } /// /// Represents a timeline event for policy evaluation flows. /// public sealed record TimelineEvent { public required TimelineEventType EventType { get; init; } public required DateTimeOffset Timestamp { get; init; } public required string RunId { get; init; } public required string Tenant { get; init; } public required string PolicyId { get; init; } public string? PolicyVersion { get; init; } public string? TraceId { get; init; } public string? SpanId { get; init; } public Dictionary? Data { get; init; } } [JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions(WriteIndented = false)] internal partial class TimelineEventJsonContext : JsonSerializerContext { }