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);
}