using System.Collections.Concurrent; using System.Diagnostics.Metrics; namespace StellaOps.Policy.Engine.Services; /// /// Metrics sink for path/scope-aware policy evaluation (POLICY-ENGINE-29-004). /// Mirrors the prep contract at docs/modules/policy/prep/2025-11-20-policy-engine-29-004-prep.md. /// internal sealed class PathScopeMetrics : IDisposable { private readonly Meter _meter; private readonly Counter _evaluations; private readonly Histogram _evaluationDurationMs; private readonly Counter _cacheHit; private readonly Counter _scopeMismatch; private readonly ConcurrentDictionary<(string Tenant, string Source), CoverageState> _coverage = new(); private readonly ObservableGauge _coverageGauge; public PathScopeMetrics() { _meter = new Meter("StellaOps.Policy.Engine", "1.0.0"); _evaluations = _meter.CreateCounter( name: "policy.path.eval.total", unit: "count", description: "Total path/scope-aware evaluations processed."); _evaluationDurationMs = _meter.CreateHistogram( name: "policy.path.eval.duration.ms", unit: "ms", description: "Latency distribution for path/scope-aware evaluations."); _cacheHit = _meter.CreateCounter( name: "policy.path.eval.cache.hit", unit: "count", description: "Cache hit/miss counts for path/scope rule lookups."); _scopeMismatch = _meter.CreateCounter( name: "policy.path.eval.scope.mismatch", unit: "count", description: "Counts of scope mismatches (depth/confidence/coverage)."); Func>> observe = ObserveCoverage; _coverageGauge = _meter.CreateObservableGauge( name: "policy.path.eval.coverage", observeValues: observe, unit: "percent", description: "Share of observations with matching scope."); } public void RecordEvaluation( string tenant, string subject, string ruleId, string pathMatch, string result, double durationMs, bool scopeMatched = true) { var evalTags = new[] { new KeyValuePair("tenant", NormalizeTenant(tenant)), new KeyValuePair("subject", NormalizeSubject(subject)), new KeyValuePair("result", NormalizeResult(result)), new KeyValuePair("ruleId", TruncateRule(ruleId)), new KeyValuePair("pathMatch", NormalizePathMatch(pathMatch)) }; _evaluations.Add(1, evalTags); var durationTags = new[] { new KeyValuePair("tenant", NormalizeTenant(tenant)), new KeyValuePair("subject", NormalizeSubject(subject)), new KeyValuePair("ruleId", TruncateRule(ruleId)) }; _evaluationDurationMs.Record(durationMs, durationTags); RecordCoverage(tenant, "path-scope", scopeMatched); } public void RecordCacheHit(string tenant, string cache, bool hit) { var tags = new[] { new KeyValuePair("tenant", NormalizeTenant(tenant)), new KeyValuePair("cache", NormalizeCache(cache)), new KeyValuePair("hit", hit ? "true" : "false") }; _cacheHit.Add(1, tags); } public void RecordScopeMismatch(string tenant, string reason) { var tags = new[] { new KeyValuePair("tenant", NormalizeTenant(tenant)), new KeyValuePair("reason", NormalizeScopeReason(reason)) }; _scopeMismatch.Add(1, tags); RecordCoverage(tenant, "path-scope", matched: false); } private void RecordCoverage(string tenant, string source, bool matched) { var key = (NormalizeTenant(tenant), NormalizeSource(source)); _coverage.AddOrUpdate( key, _ => matched ? new CoverageState(1, 1) : new CoverageState(0, 1), (_, state) => matched ? new CoverageState(state.Matched + 1, state.Total + 1) : new CoverageState(state.Matched, state.Total + 1)); } private IEnumerable> ObserveCoverage() { foreach (var kvp in _coverage) { var state = kvp.Value; if (state.Total == 0) { continue; } var percentage = state.Matched * 100.0 / state.Total; yield return new Measurement( percentage, new[] { new KeyValuePair("tenant", kvp.Key.Tenant), new KeyValuePair("source", kvp.Key.Source) }); } } private static string NormalizeTenant(string tenant) => string.IsNullOrWhiteSpace(tenant) ? "unknown" : tenant; private static string NormalizeSubject(string subject) => string.IsNullOrWhiteSpace(subject) ? "unknown" : subject; private static string NormalizeResult(string result) => result switch { "allow" or "deny" or "error" => result, _ => "deny" }; private static string NormalizePathMatch(string pathMatch) => pathMatch switch { "exact" or "prefix" or "glob" => pathMatch, _ => "unknown" }; private static string NormalizeCache(string cache) => string.IsNullOrWhiteSpace(cache) ? "decision" : cache; private static string NormalizeScopeReason(string reason) => string.IsNullOrWhiteSpace(reason) ? "no-scope" : reason; private static string NormalizeSource(string source) => string.IsNullOrWhiteSpace(source) ? "path-scope" : source; private static string TruncateRule(string ruleId) { if (string.IsNullOrWhiteSpace(ruleId)) { return "unspecified"; } return ruleId.Length <= 32 ? ruleId : ruleId[..32]; } public void Dispose() { _meter.Dispose(); } private readonly record struct CoverageState(long Matched, long Total); }