work work ... haaaard work

This commit is contained in:
StellaOps Bot
2025-11-24 00:34:20 +02:00
parent 0d4a986b7b
commit bb709b643e
36 changed files with 933 additions and 197 deletions

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