186 lines
6.3 KiB
C#
186 lines
6.3 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics.Metrics;
|
|
|
|
namespace StellaOps.Policy.Engine.Services;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal sealed class PathScopeMetrics : IDisposable
|
|
{
|
|
private readonly Meter _meter;
|
|
private readonly Counter<long> _evaluations;
|
|
private readonly Histogram<double> _evaluationDurationMs;
|
|
private readonly Counter<long> _cacheHit;
|
|
private readonly Counter<long> _scopeMismatch;
|
|
private readonly ConcurrentDictionary<(string Tenant, string Source), CoverageState> _coverage = new();
|
|
private readonly ObservableGauge<double> _coverageGauge;
|
|
|
|
public PathScopeMetrics()
|
|
{
|
|
_meter = new Meter("StellaOps.Policy.Engine", "1.0.0");
|
|
|
|
_evaluations = _meter.CreateCounter<long>(
|
|
name: "policy.path.eval.total",
|
|
unit: "count",
|
|
description: "Total path/scope-aware evaluations processed.");
|
|
|
|
_evaluationDurationMs = _meter.CreateHistogram<double>(
|
|
name: "policy.path.eval.duration.ms",
|
|
unit: "ms",
|
|
description: "Latency distribution for path/scope-aware evaluations.");
|
|
|
|
_cacheHit = _meter.CreateCounter<long>(
|
|
name: "policy.path.eval.cache.hit",
|
|
unit: "count",
|
|
description: "Cache hit/miss counts for path/scope rule lookups.");
|
|
|
|
_scopeMismatch = _meter.CreateCounter<long>(
|
|
name: "policy.path.eval.scope.mismatch",
|
|
unit: "count",
|
|
description: "Counts of scope mismatches (depth/confidence/coverage).");
|
|
|
|
Func<IEnumerable<Measurement<double>>> observe = ObserveCoverage;
|
|
_coverageGauge = _meter.CreateObservableGauge<double>(
|
|
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<string, object?>("tenant", NormalizeTenant(tenant)),
|
|
new KeyValuePair<string, object?>("subject", NormalizeSubject(subject)),
|
|
new KeyValuePair<string, object?>("result", NormalizeResult(result)),
|
|
new KeyValuePair<string, object?>("ruleId", TruncateRule(ruleId)),
|
|
new KeyValuePair<string, object?>("pathMatch", NormalizePathMatch(pathMatch))
|
|
};
|
|
|
|
_evaluations.Add(1, evalTags);
|
|
|
|
var durationTags = new[]
|
|
{
|
|
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
|
new KeyValuePair<string, object?>("subject", NormalizeSubject(subject)),
|
|
new KeyValuePair<string, object?>("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<string, object?>("tenant", NormalizeTenant(tenant)),
|
|
new KeyValuePair<string, object?>("cache", NormalizeCache(cache)),
|
|
new KeyValuePair<string, object?>("hit", hit ? "true" : "false")
|
|
};
|
|
|
|
_cacheHit.Add(1, tags);
|
|
}
|
|
|
|
public void RecordScopeMismatch(string tenant, string reason)
|
|
{
|
|
var tags = new[]
|
|
{
|
|
new KeyValuePair<string, object?>("tenant", NormalizeTenant(tenant)),
|
|
new KeyValuePair<string, object?>("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<Measurement<double>> 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<double>(
|
|
percentage,
|
|
new[]
|
|
{
|
|
new KeyValuePair<string, object?>("tenant", kvp.Key.Tenant),
|
|
new KeyValuePair<string, object?>("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);
|
|
}
|