using System.Diagnostics; namespace StellaOps.TestKit.Observability; /// /// Assertion helpers for OpenTelemetry contract testing. /// /// /// These assertions validate that telemetry conforms to expected contracts: /// required spans, attributes, cardinality limits, and schema compliance. /// /// Usage: /// /// 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); /// /// public static class OTelContractAssert { /// /// Asserts that all required span names are present in the capture. /// /// The OTel capture containing recorded spans. /// Required span names that must all be present. /// Thrown when required spans are missing. 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)}]"); } } /// /// Asserts that a span has all required attributes. /// /// The span (Activity) to check. /// Required attribute names. /// Thrown when required attributes are missing. 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)}]"); } } /// /// Asserts that an attribute's cardinality (number of unique values) is within bounds. /// /// The OTel capture containing recorded spans. /// The attribute to check. /// Maximum allowed unique values. /// Thrown when cardinality exceeds threshold. 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."); } } /// /// Asserts that no attribute exceeds the cardinality threshold across all spans. /// /// The OTel capture containing recorded spans. /// Maximum cardinality threshold (default 100). /// Thrown when any attribute exceeds threshold. 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}"); } } /// /// Asserts that span names follow the expected naming convention. /// /// The OTel capture containing recorded spans. /// Regex pattern that span names should match (e.g., "^[A-Z][a-z]+\\.[A-Z][a-z]+$"). /// Thrown when span names don't match pattern. 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)}]"); } } /// /// Asserts that all spans have a status code set (not Unset). /// /// The OTel capture containing recorded spans. /// Thrown when spans have Unset status. 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)}]"); } } /// /// Asserts that error spans have the expected error attributes. /// /// The OTel capture containing recorded spans. /// Attributes required on error spans (e.g., "exception.type", "exception.message"). /// Thrown when error spans are missing required attributes. 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)}]"); } } } /// /// Asserts that spans don't contain sensitive data patterns in their attributes. /// /// The OTel capture containing recorded spans. /// Regex patterns for sensitive data (e.g., email, SSN, credit card). /// Thrown when sensitive data is detected. 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}'"); } } } } } }