Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Engine/Telemetry/PolicyTimelineEvents.cs
master e950474a77
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
up
2025-11-27 15:16:31 +02:00

472 lines
14 KiB
C#

using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.Telemetry;
/// <summary>
/// Provides structured timeline events for policy evaluation and decision flows.
/// Events are emitted as structured logs with correlation to traces.
/// </summary>
public sealed class PolicyTimelineEvents
{
private readonly ILogger<PolicyTimelineEvents> _logger;
private readonly TimeProvider _timeProvider;
public PolicyTimelineEvents(ILogger<PolicyTimelineEvents> logger, TimeProvider timeProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
#region Evaluation Flow Events
/// <summary>
/// Emits an event when a policy evaluation run starts.
/// </summary>
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<string, object?>
{
["mode"] = mode,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when a policy evaluation run completes.
/// </summary>
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<string, object?>
{
["outcome"] = outcome,
["duration_seconds"] = durationSeconds,
["findings_count"] = findingsCount,
["determinism_hash"] = determinismHash,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when a batch selection phase starts.
/// </summary>
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<string, object?>
{
["batch_number"] = batchNumber,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when a batch selection phase completes.
/// </summary>
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<string, object?>
{
["batch_number"] = batchNumber,
["tuple_count"] = tupleCount,
["duration_seconds"] = durationSeconds,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when batch evaluation starts.
/// </summary>
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<string, object?>
{
["batch_number"] = batchNumber,
["tuple_count"] = tupleCount,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when batch evaluation completes.
/// </summary>
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<string, object?>
{
["batch_number"] = batchNumber,
["rules_evaluated"] = rulesEvaluated,
["rules_fired"] = rulesFired,
["duration_seconds"] = durationSeconds,
},
};
LogTimelineEvent(evt);
}
#endregion
#region Decision Flow Events
/// <summary>
/// Emits an event when a rule matches during evaluation.
/// </summary>
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<string, object?>
{
["rule_id"] = ruleId,
["finding_key"] = findingKey,
["severity"] = severity,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when a VEX override is applied.
/// </summary>
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<string, object?>
{
["finding_key"] = findingKey,
["vendor"] = vendor,
["status"] = status,
["justification"] = justification,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when a final verdict is determined for a finding.
/// </summary>
public void EmitVerdictDetermined(
string runId,
string tenant,
string policyId,
string findingKey,
string verdict,
string severity,
string? reachabilityState = null,
IReadOnlyList<string>? 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<string, object?>
{
["finding_key"] = findingKey,
["verdict"] = verdict,
["severity"] = severity,
["reachability_state"] = reachabilityState,
["contributing_rules"] = contributingRules,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when materialization of findings starts.
/// </summary>
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<string, object?>
{
["findings_count"] = findingsCount,
},
};
LogTimelineEvent(evt);
}
/// <summary>
/// Emits an event when materialization of findings completes.
/// </summary>
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<string, object?>
{
["findings_written"] = findingsWritten,
["findings_updated"] = findingsUpdated,
["duration_seconds"] = durationSeconds,
},
};
LogTimelineEvent(evt);
}
#endregion
#region Error Events
/// <summary>
/// Emits an event when an error occurs during evaluation.
/// </summary>
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<string, object?>
{
["error_code"] = errorCode,
["error_message"] = errorMessage,
["phase"] = phase,
},
};
LogTimelineEvent(evt, LogLevel.Error);
}
/// <summary>
/// Emits an event when a determinism violation is detected.
/// </summary>
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<string, object?>
{
["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));
}
}
/// <summary>
/// Types of timeline events emitted during policy evaluation.
/// </summary>
public enum TimelineEventType
{
RunStarted,
RunCompleted,
SelectionStarted,
SelectionCompleted,
EvaluationStarted,
EvaluationCompleted,
RuleMatched,
VexOverrideApplied,
VerdictDetermined,
MaterializationStarted,
MaterializationCompleted,
Error,
DeterminismViolation,
}
/// <summary>
/// Represents a timeline event for policy evaluation flows.
/// </summary>
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<string, object?>? Data { get; init; }
}
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSourceGenerationOptions(WriteIndented = false)]
internal partial class TimelineEventJsonContext : JsonSerializerContext
{
}