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

224 lines
9.4 KiB
C#

using System.Diagnostics;
namespace StellaOps.TestKit.Observability;
/// <summary>
/// Assertion helpers for OpenTelemetry contract testing.
/// </summary>
/// <remarks>
/// These assertions validate that telemetry conforms to expected contracts:
/// required spans, attributes, cardinality limits, and schema compliance.
///
/// Usage:
/// <code>
/// using var capture = new OtelCapture("MyService");
/// await service.ProcessAsync();
///
/// OTelContractAssert.HasRequiredSpans(capture, "ProcessRequest", "ValidateInput", "SaveResult");
/// OTelContractAssert.SpanHasAttributes(capture.CapturedActivities[0], "user_id", "tenant_id");
/// OTelContractAssert.NoHighCardinalityAttributes(capture, threshold: 100);
/// </code>
/// </remarks>
public static class OTelContractAssert
{
/// <summary>
/// Asserts that all required span names are present in the capture.
/// </summary>
/// <param name="capture">The OTel capture containing recorded spans.</param>
/// <param name="spanNames">Required span names that must all be present.</param>
/// <exception cref="ContractViolationException">Thrown when required spans are missing.</exception>
public static void HasRequiredSpans(OtelCapture capture, params string[] spanNames)
{
ArgumentNullException.ThrowIfNull(capture);
ArgumentNullException.ThrowIfNull(spanNames);
var capturedNames = capture.CapturedActivities
.Select(a => a.DisplayName ?? a.OperationName)
.ToHashSet(StringComparer.Ordinal);
var missing = spanNames.Where(name => !capturedNames.Contains(name)).ToList();
if (missing.Count > 0)
{
throw new ContractViolationException(
$"Missing required spans: [{string.Join(", ", missing)}]. " +
$"Captured spans: [{string.Join(", ", capturedNames)}]");
}
}
/// <summary>
/// Asserts that a span has all required attributes.
/// </summary>
/// <param name="span">The span (Activity) to check.</param>
/// <param name="attributeNames">Required attribute names.</param>
/// <exception cref="ContractViolationException">Thrown when required attributes are missing.</exception>
public static void SpanHasAttributes(Activity span, params string[] attributeNames)
{
ArgumentNullException.ThrowIfNull(span);
ArgumentNullException.ThrowIfNull(attributeNames);
var spanAttributes = span.Tags.Select(t => t.Key).ToHashSet(StringComparer.Ordinal);
var missing = attributeNames.Where(name => !spanAttributes.Contains(name)).ToList();
if (missing.Count > 0)
{
throw new ContractViolationException(
$"Span '{span.DisplayName}' missing required attributes: [{string.Join(", ", missing)}]. " +
$"Present attributes: [{string.Join(", ", spanAttributes)}]");
}
}
/// <summary>
/// Asserts that an attribute's cardinality (number of unique values) is within bounds.
/// </summary>
/// <param name="capture">The OTel capture containing recorded spans.</param>
/// <param name="attributeName">The attribute to check.</param>
/// <param name="maxCardinality">Maximum allowed unique values.</param>
/// <exception cref="ContractViolationException">Thrown when cardinality exceeds threshold.</exception>
public static void AttributeCardinality(OtelCapture capture, string attributeName, int maxCardinality)
{
ArgumentNullException.ThrowIfNull(capture);
var uniqueValues = capture.CapturedActivities
.SelectMany(a => a.Tags)
.Where(t => t.Key == attributeName)
.Select(t => t.Value)
.Distinct()
.Count();
if (uniqueValues > maxCardinality)
{
throw new ContractViolationException(
$"Attribute '{attributeName}' has cardinality {uniqueValues}, exceeds max {maxCardinality}. " +
"High cardinality attributes can cause metric explosion and storage issues.");
}
}
/// <summary>
/// Asserts that no attribute exceeds the cardinality threshold across all spans.
/// </summary>
/// <param name="capture">The OTel capture containing recorded spans.</param>
/// <param name="threshold">Maximum cardinality threshold (default 100).</param>
/// <exception cref="ContractViolationException">Thrown when any attribute exceeds threshold.</exception>
public static void NoHighCardinalityAttributes(OtelCapture capture, int threshold = 100)
{
ArgumentNullException.ThrowIfNull(capture);
var cardinalityByAttribute = capture.CapturedActivities
.SelectMany(a => a.Tags)
.GroupBy(t => t.Key)
.Select(g => new { Attribute = g.Key, Cardinality = g.Select(t => t.Value).Distinct().Count() })
.Where(x => x.Cardinality > threshold)
.ToList();
if (cardinalityByAttribute.Count > 0)
{
var violations = string.Join(", ",
cardinalityByAttribute.Select(x => $"{x.Attribute}={x.Cardinality}"));
throw new ContractViolationException(
$"High cardinality attributes detected (threshold={threshold}): {violations}");
}
}
/// <summary>
/// Asserts that span names follow the expected naming convention.
/// </summary>
/// <param name="capture">The OTel capture containing recorded spans.</param>
/// <param name="pattern">Regex pattern that span names should match (e.g., "^[A-Z][a-z]+\\.[A-Z][a-z]+$").</param>
/// <exception cref="ContractViolationException">Thrown when span names don't match pattern.</exception>
public static void SpanNamesMatchPattern(OtelCapture capture, string pattern)
{
ArgumentNullException.ThrowIfNull(capture);
var regex = new System.Text.RegularExpressions.Regex(pattern);
var violating = capture.CapturedActivities
.Select(a => a.DisplayName ?? a.OperationName)
.Where(name => !regex.IsMatch(name))
.ToList();
if (violating.Count > 0)
{
throw new ContractViolationException(
$"Span names violate naming convention '{pattern}': [{string.Join(", ", violating)}]");
}
}
/// <summary>
/// Asserts that all spans have a status code set (not Unset).
/// </summary>
/// <param name="capture">The OTel capture containing recorded spans.</param>
/// <exception cref="ContractViolationException">Thrown when spans have Unset status.</exception>
public static void AllSpansHaveStatus(OtelCapture capture)
{
ArgumentNullException.ThrowIfNull(capture);
var unsetSpans = capture.CapturedActivities
.Where(a => a.Status == ActivityStatusCode.Unset)
.Select(a => a.DisplayName ?? a.OperationName)
.ToList();
if (unsetSpans.Count > 0)
{
throw new ContractViolationException(
$"Spans with unset status (should be Ok or Error): [{string.Join(", ", unsetSpans)}]");
}
}
/// <summary>
/// Asserts that error spans have the expected error attributes.
/// </summary>
/// <param name="capture">The OTel capture containing recorded spans.</param>
/// <param name="requiredErrorAttributes">Attributes required on error spans (e.g., "exception.type", "exception.message").</param>
/// <exception cref="ContractViolationException">Thrown when error spans are missing required attributes.</exception>
public static void ErrorSpansHaveAttributes(OtelCapture capture, params string[] requiredErrorAttributes)
{
ArgumentNullException.ThrowIfNull(capture);
var errorSpans = capture.CapturedActivities
.Where(a => a.Status == ActivityStatusCode.Error)
.ToList();
foreach (var span in errorSpans)
{
var spanAttributes = span.Tags.Select(t => t.Key).ToHashSet(StringComparer.Ordinal);
var missing = requiredErrorAttributes.Where(attr => !spanAttributes.Contains(attr)).ToList();
if (missing.Count > 0)
{
throw new ContractViolationException(
$"Error span '{span.DisplayName}' missing required error attributes: [{string.Join(", ", missing)}]");
}
}
}
/// <summary>
/// Asserts that spans don't contain sensitive data patterns in their attributes.
/// </summary>
/// <param name="capture">The OTel capture containing recorded spans.</param>
/// <param name="sensitivePatterns">Regex patterns for sensitive data (e.g., email, SSN, credit card).</param>
/// <exception cref="ContractViolationException">Thrown when sensitive data is detected.</exception>
public static void NoSensitiveDataInSpans(OtelCapture capture, params System.Text.RegularExpressions.Regex[] sensitivePatterns)
{
ArgumentNullException.ThrowIfNull(capture);
foreach (var span in capture.CapturedActivities)
{
foreach (var tag in span.Tags)
{
if (tag.Value == null) continue;
foreach (var pattern in sensitivePatterns)
{
if (pattern.IsMatch(tag.Value))
{
throw new ContractViolationException(
$"Potential sensitive data in span '{span.DisplayName}', attribute '{tag.Key}': " +
$"value matches pattern '{pattern}'");
}
}
}
}
}
}