224 lines
9.4 KiB
C#
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}'");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|