using System.Diagnostics.Metrics;
namespace StellaOps.TestKit.Observability;
///
/// Assertion helpers for metrics contract testing.
///
///
/// These assertions validate that metrics conform to expected contracts:
/// metric existence, label cardinality, monotonicity, and naming conventions.
///
/// Usage:
///
/// 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");
///
///
public static class MetricsContractAssert
{
///
/// Asserts that a metric with the specified name exists.
///
/// The metrics capture.
/// The expected metric name.
/// Thrown when metric doesn't exist.
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)}]");
}
}
///
/// Asserts that a metric's label cardinality is within bounds.
///
/// The metrics capture.
/// The metric to check.
/// Maximum allowed unique label combinations.
/// Thrown when cardinality exceeds threshold.
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.");
}
}
///
/// Asserts that a counter metric is monotonically increasing.
///
/// The metrics capture.
/// The counter metric to check.
/// Thrown when counter decreases.
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;
}
}
///
/// Asserts that a gauge metric stays within expected bounds.
///
/// The metrics capture.
/// The gauge metric to check.
/// Minimum acceptable value.
/// Maximum acceptable value.
/// Thrown when gauge exceeds bounds.
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}]");
}
}
}
///
/// Asserts that metric names follow the expected naming convention.
///
/// The metrics capture.
/// Regex pattern for metric names (e.g., "^[a-z_]+_total$" for counters).
/// Thrown when metric names don't match pattern.
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)}]");
}
}
///
/// Asserts that required metrics are present.
///
/// The metrics capture.
/// Required metric names.
/// Thrown when required metrics are missing.
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)}]");
}
}
///
/// Asserts that no metrics have unbounded label values.
///
/// The metrics capture.
/// Patterns indicating unbounded values (e.g., IDs, timestamps).
/// Thrown when unbounded labels are detected.
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}'");
}
}
}
}
}
}
}
///
/// Captures metrics for contract testing.
///
public sealed class MetricsCapture : IDisposable
{
private readonly Dictionary> _measurements = new();
private readonly MeterListener _listener;
private bool _disposed;
///
/// Creates a new metrics capture.
///
/// Optional meter name filter.
public MetricsCapture(string? meterName = null)
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (meterName == null || instrument.Meter.Name == meterName)
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback(OnMeasurement);
_listener.SetMeasurementEventCallback(OnMeasurementLong);
_listener.SetMeasurementEventCallback(OnMeasurementInt);
_listener.Start();
}
private void OnMeasurement(Instrument instrument, double measurement,
ReadOnlySpan> tags, object? state)
{
RecordMeasurement(instrument.Name, measurement, tags);
}
private void OnMeasurementLong(Instrument instrument, long measurement,
ReadOnlySpan> tags, object? state)
{
RecordMeasurement(instrument.Name, measurement, tags);
}
private void OnMeasurementInt(Instrument instrument, int measurement,
ReadOnlySpan> tags, object? state)
{
RecordMeasurement(instrument.Name, measurement, tags);
}
private void RecordMeasurement(string name, double value, ReadOnlySpan> tags)
{
lock (_measurements)
{
if (!_measurements.TryGetValue(name, out var list))
{
list = new List();
_measurements[name] = list;
}
list.Add(new MetricMeasurement
{
Value = value,
Tags = tags.ToArray().ToDictionary(
t => t.Key,
t => t.Value?.ToString() ?? ""),
Timestamp = DateTimeOffset.UtcNow
});
}
}
///
/// Gets all metric names that have been recorded.
///
public IReadOnlyList MetricNames
{
get
{
lock (_measurements)
{
return _measurements.Keys.ToList();
}
}
}
///
/// Checks if a metric has been recorded.
///
public bool HasMetric(string name)
{
lock (_measurements)
{
return _measurements.ContainsKey(name);
}
}
///
/// Gets all recorded values for a metric.
///
public IReadOnlyList GetValues(string metricName)
{
lock (_measurements)
{
if (_measurements.TryGetValue(metricName, out var list))
{
return list.Select(m => m.Value).ToList();
}
return Array.Empty();
}
}
///
/// Gets the cardinality (number of unique label combinations) for a metric.
///
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;
}
}
///
/// Gets all unique label values for a metric.
///
public IReadOnlyDictionary> GetLabels(string metricName)
{
lock (_measurements)
{
if (!_measurements.TryGetValue(metricName, out var list))
{
return new Dictionary>();
}
var result = new Dictionary>();
foreach (var measurement in list)
{
foreach (var (key, value) in measurement.Tags)
{
if (!result.TryGetValue(key, out var values))
{
values = new HashSet();
result[key] = values;
}
values.Add(value);
}
}
return result.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList)kvp.Value.ToList());
}
}
///
public void Dispose()
{
if (_disposed) return;
_listener.Dispose();
_disposed = true;
}
private sealed record MetricMeasurement
{
public double Value { get; init; }
public Dictionary Tags { get; init; } = new();
public DateTimeOffset Timestamp { get; init; }
}
}