test fixes and new product advisories work
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user