work work ... haaaard work
This commit is contained in:
185
src/Policy/StellaOps.Policy.Engine/Services/PathScopeMetrics.cs
Normal file
185
src/Policy/StellaOps.Policy.Engine/Services/PathScopeMetrics.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user