Files
git.stella-ops.org/src/__Libraries/StellaOps.TestKit/Observability/MetricsContractAssert.cs
2026-01-28 02:30:48 +02:00

361 lines
12 KiB
C#

using System.Diagnostics.Metrics;
namespace StellaOps.TestKit.Observability;
/// <summary>
/// Assertion helpers for metrics contract testing.
/// </summary>
/// <remarks>
/// These assertions validate that metrics conform to expected contracts:
/// metric existence, label cardinality, monotonicity, and naming conventions.
///
/// Usage:
/// <code>
/// var capture = new MetricsCapture("MyService");
/// await service.ProcessAsync();
///
/// MetricsContractAssert.MetricExists(capture, "requests_total");
/// MetricsContractAssert.LabelCardinalityBounded(capture, "http_requests_total", maxLabels: 50);
/// MetricsContractAssert.CounterMonotonic(capture, "processed_items_total");
/// </code>
/// </remarks>
public static class MetricsContractAssert
{
/// <summary>
/// Asserts that a metric with the specified name exists.
/// </summary>
/// <param name="capture">The metrics capture.</param>
/// <param name="metricName">The expected metric name.</param>
/// <exception cref="ContractViolationException">Thrown when metric doesn't exist.</exception>
public static void MetricExists(MetricsCapture capture, string metricName)
{
ArgumentNullException.ThrowIfNull(capture);
if (!capture.HasMetric(metricName))
{
throw new ContractViolationException(
$"Expected metric '{metricName}' not found. " +
$"Available metrics: [{string.Join(", ", capture.MetricNames)}]");
}
}
/// <summary>
/// Asserts that a metric's label cardinality is within bounds.
/// </summary>
/// <param name="capture">The metrics capture.</param>
/// <param name="metricName">The metric to check.</param>
/// <param name="maxLabels">Maximum allowed unique label combinations.</param>
/// <exception cref="ContractViolationException">Thrown when cardinality exceeds threshold.</exception>
public static void LabelCardinalityBounded(MetricsCapture capture, string metricName, int maxLabels)
{
ArgumentNullException.ThrowIfNull(capture);
var cardinality = capture.GetLabelCardinality(metricName);
if (cardinality > maxLabels)
{
throw new ContractViolationException(
$"Metric '{metricName}' has cardinality {cardinality}, exceeds max {maxLabels}. " +
"High cardinality metrics cause storage and performance issues.");
}
}
/// <summary>
/// Asserts that a counter metric is monotonically increasing.
/// </summary>
/// <param name="capture">The metrics capture.</param>
/// <param name="metricName">The counter metric to check.</param>
/// <exception cref="ContractViolationException">Thrown when counter decreases.</exception>
public static void CounterMonotonic(MetricsCapture capture, string metricName)
{
ArgumentNullException.ThrowIfNull(capture);
var values = capture.GetValues(metricName);
double? previous = null;
foreach (var value in values)
{
if (previous.HasValue && value < previous.Value)
{
throw new ContractViolationException(
$"Counter '{metricName}' is not monotonic: decreased from {previous} to {value}");
}
previous = value;
}
}
/// <summary>
/// Asserts that a gauge metric stays within expected bounds.
/// </summary>
/// <param name="capture">The metrics capture.</param>
/// <param name="metricName">The gauge metric to check.</param>
/// <param name="minValue">Minimum acceptable value.</param>
/// <param name="maxValue">Maximum acceptable value.</param>
/// <exception cref="ContractViolationException">Thrown when gauge exceeds bounds.</exception>
public static void GaugeInBounds(MetricsCapture capture, string metricName, double minValue, double maxValue)
{
ArgumentNullException.ThrowIfNull(capture);
var values = capture.GetValues(metricName);
foreach (var value in values)
{
if (value < minValue || value > maxValue)
{
throw new ContractViolationException(
$"Gauge '{metricName}' value {value} outside bounds [{minValue}, {maxValue}]");
}
}
}
/// <summary>
/// Asserts that metric names follow the expected naming convention.
/// </summary>
/// <param name="capture">The metrics capture.</param>
/// <param name="pattern">Regex pattern for metric names (e.g., "^[a-z_]+_total$" for counters).</param>
/// <exception cref="ContractViolationException">Thrown when metric names don't match pattern.</exception>
public static void MetricNamesMatchPattern(MetricsCapture capture, string pattern)
{
ArgumentNullException.ThrowIfNull(capture);
var regex = new System.Text.RegularExpressions.Regex(pattern);
var violating = capture.MetricNames.Where(name => !regex.IsMatch(name)).ToList();
if (violating.Count > 0)
{
throw new ContractViolationException(
$"Metric names violate naming convention '{pattern}': [{string.Join(", ", violating)}]");
}
}
/// <summary>
/// Asserts that required metrics are present.
/// </summary>
/// <param name="capture">The metrics capture.</param>
/// <param name="metricNames">Required metric names.</param>
/// <exception cref="ContractViolationException">Thrown when required metrics are missing.</exception>
public static void HasRequiredMetrics(MetricsCapture capture, params string[] metricNames)
{
ArgumentNullException.ThrowIfNull(capture);
var missing = metricNames.Where(name => !capture.HasMetric(name)).ToList();
if (missing.Count > 0)
{
throw new ContractViolationException(
$"Missing required metrics: [{string.Join(", ", missing)}]");
}
}
/// <summary>
/// Asserts that no metrics have unbounded label values.
/// </summary>
/// <param name="capture">The metrics capture.</param>
/// <param name="forbiddenLabelPatterns">Patterns indicating unbounded values (e.g., IDs, timestamps).</param>
/// <exception cref="ContractViolationException">Thrown when unbounded labels are detected.</exception>
public static void NoUnboundedLabels(MetricsCapture capture, params System.Text.RegularExpressions.Regex[] forbiddenLabelPatterns)
{
ArgumentNullException.ThrowIfNull(capture);
foreach (var metricName in capture.MetricNames)
{
var labels = capture.GetLabels(metricName);
foreach (var (labelName, labelValues) in labels)
{
foreach (var value in labelValues)
{
foreach (var pattern in forbiddenLabelPatterns)
{
if (pattern.IsMatch(value))
{
throw new ContractViolationException(
$"Metric '{metricName}' has potentially unbounded label '{labelName}': " +
$"value '{value}' matches pattern '{pattern}'");
}
}
}
}
}
}
}
/// <summary>
/// Captures metrics for contract testing.
/// </summary>
public sealed class MetricsCapture : IDisposable
{
private readonly Dictionary<string, List<MetricMeasurement>> _measurements = new();
private readonly MeterListener _listener;
private bool _disposed;
/// <summary>
/// Creates a new metrics capture.
/// </summary>
/// <param name="meterName">Optional meter name filter.</param>
public MetricsCapture(string? meterName = null)
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (meterName == null || instrument.Meter.Name == meterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<double>(OnMeasurement);
_listener.SetMeasurementEventCallback<long>(OnMeasurementLong);
_listener.SetMeasurementEventCallback<int>(OnMeasurementInt);
_listener.Start();
}
private void OnMeasurement(Instrument instrument, double measurement,
ReadOnlySpan<KeyValuePair<string, object?>> tags, object? state)
{
RecordMeasurement(instrument.Name, measurement, tags);
}
private void OnMeasurementLong(Instrument instrument, long measurement,
ReadOnlySpan<KeyValuePair<string, object?>> tags, object? state)
{
RecordMeasurement(instrument.Name, measurement, tags);
}
private void OnMeasurementInt(Instrument instrument, int measurement,
ReadOnlySpan<KeyValuePair<string, object?>> tags, object? state)
{
RecordMeasurement(instrument.Name, measurement, tags);
}
private void RecordMeasurement(string name, double value, ReadOnlySpan<KeyValuePair<string, object?>> tags)
{
lock (_measurements)
{
if (!_measurements.TryGetValue(name, out var list))
{
list = new List<MetricMeasurement>();
_measurements[name] = list;
}
list.Add(new MetricMeasurement
{
Value = value,
Tags = tags.ToArray().ToDictionary(
t => t.Key,
t => t.Value?.ToString() ?? ""),
Timestamp = DateTimeOffset.UtcNow
});
}
}
/// <summary>
/// Gets all metric names that have been recorded.
/// </summary>
public IReadOnlyList<string> MetricNames
{
get
{
lock (_measurements)
{
return _measurements.Keys.ToList();
}
}
}
/// <summary>
/// Checks if a metric has been recorded.
/// </summary>
public bool HasMetric(string name)
{
lock (_measurements)
{
return _measurements.ContainsKey(name);
}
}
/// <summary>
/// Gets all recorded values for a metric.
/// </summary>
public IReadOnlyList<double> GetValues(string metricName)
{
lock (_measurements)
{
if (_measurements.TryGetValue(metricName, out var list))
{
return list.Select(m => m.Value).ToList();
}
return Array.Empty<double>();
}
}
/// <summary>
/// Gets the cardinality (number of unique label combinations) for a metric.
/// </summary>
public int GetLabelCardinality(string metricName)
{
lock (_measurements)
{
if (_measurements.TryGetValue(metricName, out var list))
{
return list
.Select(m => string.Join(",", m.Tags.OrderBy(t => t.Key).Select(t => $"{t.Key}={t.Value}")))
.Distinct()
.Count();
}
return 0;
}
}
/// <summary>
/// Gets all unique label values for a metric.
/// </summary>
public IReadOnlyDictionary<string, IReadOnlyList<string>> GetLabels(string metricName)
{
lock (_measurements)
{
if (!_measurements.TryGetValue(metricName, out var list))
{
return new Dictionary<string, IReadOnlyList<string>>();
}
var result = new Dictionary<string, HashSet<string>>();
foreach (var measurement in list)
{
foreach (var (key, value) in measurement.Tags)
{
if (!result.TryGetValue(key, out var values))
{
values = new HashSet<string>();
result[key] = values;
}
values.Add(value);
}
}
return result.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<string>)kvp.Value.ToList());
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_listener.Dispose();
_disposed = true;
}
private sealed record MetricMeasurement
{
public double Value { get; init; }
public Dictionary<string, string> Tags { get; init; } = new();
public DateTimeOffset Timestamp { get; init; }
}
}