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}'");
}
}
}
}
}
}