361 lines
12 KiB
C#
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; }
|
|
}
|
|
}
|