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