up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
|
||||
namespace StellaOps.Telemetry.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes metric label usage to prevent high-cardinality labels and enforce naming conventions.
|
||||
/// </summary>
|
||||
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||
public sealed class MetricLabelAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic ID for high-cardinality label patterns.
|
||||
/// </summary>
|
||||
public const string HighCardinalityDiagnosticId = "TELEM001";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for invalid label key format.
|
||||
/// </summary>
|
||||
public const string InvalidLabelKeyDiagnosticId = "TELEM002";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for dynamic label values.
|
||||
/// </summary>
|
||||
public const string DynamicLabelDiagnosticId = "TELEM003";
|
||||
|
||||
private static readonly LocalizableString HighCardinalityTitle = "Potential high-cardinality metric label detected";
|
||||
private static readonly LocalizableString HighCardinalityMessage = "Label key '{0}' may cause high cardinality. Avoid using IDs, timestamps, or user-specific values as labels.";
|
||||
private static readonly LocalizableString HighCardinalityDescription = "High-cardinality labels can cause memory exhaustion and poor query performance. Use bounded, categorical values instead.";
|
||||
|
||||
private static readonly LocalizableString InvalidKeyTitle = "Invalid metric label key format";
|
||||
private static readonly LocalizableString InvalidKeyMessage = "Label key '{0}' should use snake_case and contain only lowercase letters, digits, and underscores.";
|
||||
private static readonly LocalizableString InvalidKeyDescription = "Metric label keys should follow Prometheus naming conventions: lowercase snake_case with only [a-z0-9_] characters.";
|
||||
|
||||
private static readonly LocalizableString DynamicLabelTitle = "Dynamic metric label value detected";
|
||||
private static readonly LocalizableString DynamicLabelMessage = "Metric label value appears to be dynamically generated. Consider using predefined constants or enums.";
|
||||
private static readonly LocalizableString DynamicLabelDescription = "Dynamic label values can lead to unbounded cardinality. Use constants, enums, or validated bounded sets.";
|
||||
|
||||
private static readonly DiagnosticDescriptor HighCardinalityRule = new(
|
||||
HighCardinalityDiagnosticId,
|
||||
HighCardinalityTitle,
|
||||
HighCardinalityMessage,
|
||||
"Performance",
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: HighCardinalityDescription);
|
||||
|
||||
private static readonly DiagnosticDescriptor InvalidKeyRule = new(
|
||||
InvalidLabelKeyDiagnosticId,
|
||||
InvalidKeyTitle,
|
||||
InvalidKeyMessage,
|
||||
"Naming",
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: InvalidKeyDescription);
|
||||
|
||||
private static readonly DiagnosticDescriptor DynamicLabelRule = new(
|
||||
DynamicLabelDiagnosticId,
|
||||
DynamicLabelTitle,
|
||||
DynamicLabelMessage,
|
||||
"Performance",
|
||||
DiagnosticSeverity.Info,
|
||||
isEnabledByDefault: true,
|
||||
description: DynamicLabelDescription);
|
||||
|
||||
// Patterns that suggest high-cardinality labels
|
||||
private static readonly string[] HighCardinalityPatterns =
|
||||
{
|
||||
"id", "guid", "uuid", "user_id", "request_id", "session_id", "transaction_id",
|
||||
"timestamp", "datetime", "time", "date",
|
||||
"email", "username", "name", "ip", "address",
|
||||
"path", "url", "uri", "query",
|
||||
"message", "error_message", "description", "body", "content"
|
||||
};
|
||||
|
||||
// Valid label key pattern: lowercase snake_case
|
||||
private static readonly Regex ValidLabelKeyPattern = new(@"^[a-z][a-z0-9_]*$", RegexOptions.Compiled);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
|
||||
ImmutableArray.Create(HighCardinalityRule, InvalidKeyRule, DynamicLabelRule);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
|
||||
// Analyze invocations of metric methods
|
||||
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
|
||||
}
|
||||
|
||||
private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
if (context.Node is not InvocationExpressionSyntax invocation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation);
|
||||
if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a metric recording method
|
||||
if (!IsMetricMethod(methodSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Analyze the arguments for label-related patterns
|
||||
foreach (var argument in invocation.ArgumentList.Arguments)
|
||||
{
|
||||
AnalyzeArgument(context, argument);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsMetricMethod(IMethodSymbol method)
|
||||
{
|
||||
var containingType = method.ContainingType?.ToDisplayString();
|
||||
|
||||
// Check for GoldenSignalMetrics methods
|
||||
if (containingType?.Contains("GoldenSignalMetrics") == true)
|
||||
{
|
||||
return method.Name is "RecordLatency" or "IncrementErrors" or "IncrementRequests" or "Tag";
|
||||
}
|
||||
|
||||
// Check for System.Diagnostics.Metrics methods
|
||||
if (containingType?.StartsWith("System.Diagnostics.Metrics.") == true)
|
||||
{
|
||||
return method.Name is "Record" or "Add" or "CreateCounter" or "CreateHistogram" or "CreateGauge";
|
||||
}
|
||||
|
||||
// Check for OpenTelemetry methods
|
||||
if (containingType?.Contains("OpenTelemetry") == true && containingType.Contains("Meter"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AnalyzeArgument(SyntaxNodeAnalysisContext context, ArgumentSyntax argument)
|
||||
{
|
||||
// Check for KeyValuePair creation (Tag method calls)
|
||||
if (argument.Expression is InvocationExpressionSyntax tagInvocation)
|
||||
{
|
||||
var tagSymbol = context.SemanticModel.GetSymbolInfo(tagInvocation).Symbol as IMethodSymbol;
|
||||
if (tagSymbol?.Name == "Tag" && tagInvocation.ArgumentList.Arguments.Count >= 2)
|
||||
{
|
||||
var keyArg = tagInvocation.ArgumentList.Arguments[0];
|
||||
var valueArg = tagInvocation.ArgumentList.Arguments[1];
|
||||
|
||||
AnalyzeLabelKey(context, keyArg.Expression);
|
||||
AnalyzeLabelValue(context, valueArg.Expression);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new KeyValuePair<string, object?>(key, value)
|
||||
if (argument.Expression is ObjectCreationExpressionSyntax objectCreation)
|
||||
{
|
||||
var typeSymbol = context.SemanticModel.GetSymbolInfo(objectCreation.Type).Symbol as INamedTypeSymbol;
|
||||
if (typeSymbol?.Name == "KeyValuePair" && objectCreation.ArgumentList?.Arguments.Count >= 2)
|
||||
{
|
||||
var keyArg = objectCreation.ArgumentList.Arguments[0];
|
||||
var valueArg = objectCreation.ArgumentList.Arguments[1];
|
||||
|
||||
AnalyzeLabelKey(context, keyArg.Expression);
|
||||
AnalyzeLabelValue(context, valueArg.Expression);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for tuple-like implicit conversions
|
||||
if (argument.Expression is TupleExpressionSyntax tuple && tuple.Arguments.Count >= 2)
|
||||
{
|
||||
AnalyzeLabelKey(context, tuple.Arguments[0].Expression);
|
||||
AnalyzeLabelValue(context, tuple.Arguments[1].Expression);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeLabelKey(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
|
||||
{
|
||||
// Get the constant value if it's a literal or const
|
||||
var constantValue = context.SemanticModel.GetConstantValue(expression);
|
||||
if (!constantValue.HasValue || constantValue.Value is not string keyString)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for valid label key format
|
||||
if (!ValidLabelKeyPattern.IsMatch(keyString))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(InvalidKeyRule, expression.GetLocation(), keyString);
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
|
||||
// Check for high-cardinality patterns
|
||||
var keyLower = keyString.ToLowerInvariant();
|
||||
foreach (var pattern in HighCardinalityPatterns)
|
||||
{
|
||||
if (keyLower.Contains(pattern))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(HighCardinalityRule, expression.GetLocation(), keyString);
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeLabelValue(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
|
||||
{
|
||||
// If the value is a literal string or const, it's fine
|
||||
var constantValue = context.SemanticModel.GetConstantValue(expression);
|
||||
if (constantValue.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's an enum member access - that's fine
|
||||
var typeInfo = context.SemanticModel.GetTypeInfo(expression);
|
||||
if (typeInfo.Type?.TypeKind == TypeKind.Enum)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's accessing a static/const field - that's fine
|
||||
if (expression is MemberAccessExpressionSyntax memberAccess)
|
||||
{
|
||||
var symbol = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol;
|
||||
if (symbol is IFieldSymbol { IsConst: true } or IFieldSymbol { IsStatic: true, IsReadOnly: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .ToString() calls on enums - that's fine
|
||||
if (expression is InvocationExpressionSyntax toStringInvocation)
|
||||
{
|
||||
if (toStringInvocation.Expression is MemberAccessExpressionSyntax toStringAccess &&
|
||||
toStringAccess.Name.Identifier.Text == "ToString")
|
||||
{
|
||||
var targetTypeInfo = context.SemanticModel.GetTypeInfo(toStringAccess.Expression);
|
||||
if (targetTypeInfo.Type?.TypeKind == TypeKind.Enum)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag potentially dynamic values
|
||||
if (expression is IdentifierNameSyntax or
|
||||
InvocationExpressionSyntax or
|
||||
InterpolatedStringExpressionSyntax or
|
||||
BinaryExpressionSyntax)
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(DynamicLabelRule, expression.GetLocation());
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Testing;
|
||||
using Xunit;
|
||||
using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier<StellaOps.Telemetry.Analyzers.MetricLabelAnalyzer>;
|
||||
|
||||
namespace StellaOps.Telemetry.Analyzers.Tests;
|
||||
|
||||
public sealed class MetricLabelAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidLabelKey_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status_code", "200"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidLabelKey_UpperCase_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag({|#0:"StatusCode"|}, "200"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.InvalidLabelKeyDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("StatusCode");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HighCardinalityLabelKey_UserId_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag({|#0:"user_id"|}, "123"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.HighCardinalityDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("user_id");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HighCardinalityLabelKey_RequestId_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void IncrementRequests(params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.IncrementRequests(GoldenSignalMetrics.Tag({|#0:"request_id"|}, "abc-123"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.HighCardinalityDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("request_id");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HighCardinalityLabelKey_Email_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void IncrementErrors(params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.IncrementErrors(GoldenSignalMetrics.Tag({|#0:"user_email"|}, "test@example.com"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.HighCardinalityDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("user_email");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicLabelValue_Variable_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(string dynamicValue)
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("operation", {|#0:dynamicValue|}));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.DynamicLabelDiagnosticId)
|
||||
.WithLocation(0);
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicLabelValue_InterpolatedString_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(int code)
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", {|#0:$"code_{code}"|}));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.DynamicLabelDiagnosticId)
|
||||
.WithLocation(0);
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticLabelValue_Constant_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
private const string StatusOk = "ok";
|
||||
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", StatusOk));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnumLabelValue_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public enum Status { Ok, Error }
|
||||
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", Status.Ok));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnumToStringLabelValue_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public enum Status { Ok, Error }
|
||||
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", Status.Ok.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TupleSyntax_ValidLabel_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(Counter<int> counter)
|
||||
{
|
||||
counter.Add(1, ("status_code", "200"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeyValuePairCreation_HighCardinalityKey_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(Counter<int> counter)
|
||||
{
|
||||
counter.Add(1, new KeyValuePair<string, object?>({|#0:"session_id"|}, "abc"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.HighCardinalityDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("session_id");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonMetricMethod_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class RegularClass
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void SomeMethod(params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var obj = new RegularClass();
|
||||
obj.SomeMethod(RegularClass.Tag("user_id", "123"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleIssues_ReportsAllDiagnostics()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(string dynamicValue)
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0,
|
||||
GoldenSignalMetrics.Tag({|#0:"UserId"|}, "static"),
|
||||
GoldenSignalMetrics.Tag("operation", {|#1:dynamicValue|}));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected1 = Verifier.Diagnostic(MetricLabelAnalyzer.InvalidLabelKeyDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("UserId");
|
||||
|
||||
var expected2 = Verifier.Diagnostic(MetricLabelAnalyzer.DynamicLabelDiagnosticId)
|
||||
.WithLocation(1);
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected1, expected2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticReadonlyField_LabelValue_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public static class Labels
|
||||
{
|
||||
public static readonly string StatusOk = "ok";
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", Labels.StatusOk));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Telemetry.Analyzers.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Description>Roslyn analyzers for StellaOps telemetry code quality, including metric label validation and cardinality guards.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.11.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test harness for validating telemetry context propagation across async resume scenarios.
|
||||
/// </summary>
|
||||
public sealed class AsyncResumeTestHarness
|
||||
{
|
||||
[Fact]
|
||||
public async Task JobScope_CaptureAndResume_PreservesContext()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var originalContext = new TelemetryContext
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
Actor = "user@example.com",
|
||||
CorrelationId = "corr-456",
|
||||
ImposedRule = "rule-789"
|
||||
};
|
||||
|
||||
accessor.Context = originalContext;
|
||||
|
||||
// Capture context for job
|
||||
var payload = TelemetryContextJobScope.CaptureForJob(accessor);
|
||||
Assert.NotNull(payload);
|
||||
|
||||
// Clear context (simulating job queue boundary)
|
||||
accessor.Context = null;
|
||||
Assert.Null(accessor.Context);
|
||||
|
||||
// Resume in new context (simulating job worker)
|
||||
using (TelemetryContextJobScope.ResumeFromJob(accessor, payload))
|
||||
{
|
||||
var resumed = accessor.Context;
|
||||
Assert.NotNull(resumed);
|
||||
Assert.Equal(originalContext.TenantId, resumed.TenantId);
|
||||
Assert.Equal(originalContext.Actor, resumed.Actor);
|
||||
Assert.Equal(originalContext.CorrelationId, resumed.CorrelationId);
|
||||
Assert.Equal(originalContext.ImposedRule, resumed.ImposedRule);
|
||||
}
|
||||
|
||||
// Context should be cleared after scope disposal
|
||||
Assert.Null(accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobScope_Resume_WithNullPayload_DoesNotThrow()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
|
||||
using (TelemetryContextJobScope.ResumeFromJob(accessor, null))
|
||||
{
|
||||
Assert.Null(accessor.Context);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobScope_Resume_WithInvalidPayload_DoesNotThrow()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
|
||||
using (TelemetryContextJobScope.ResumeFromJob(accessor, "not-valid-json"))
|
||||
{
|
||||
Assert.Null(accessor.Context);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JobScope_CreateQueueHeaders_IncludesAllContextFields()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
accessor.Context = new TelemetryContext
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
Actor = "user@example.com",
|
||||
CorrelationId = "corr-456",
|
||||
ImposedRule = "rule-789"
|
||||
};
|
||||
|
||||
var headers = TelemetryContextJobScope.CreateQueueHeaders(accessor);
|
||||
|
||||
Assert.Equal("tenant-123", headers["X-Tenant-Id"]);
|
||||
Assert.Equal("user@example.com", headers["X-Actor"]);
|
||||
Assert.Equal("corr-456", headers["X-Correlation-Id"]);
|
||||
Assert.Equal("rule-789", headers["X-Imposed-Rule"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_Propagates_AcrossSimulatedJobQueue()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var jobQueue = new ConcurrentQueue<string>();
|
||||
var results = new ConcurrentDictionary<string, string?>();
|
||||
|
||||
// Producer: enqueue jobs with context
|
||||
accessor.Context = new TelemetryContext { TenantId = "tenant-A", CorrelationId = "job-1" };
|
||||
jobQueue.Enqueue(TelemetryContextJobScope.CaptureForJob(accessor)!);
|
||||
|
||||
accessor.Context = new TelemetryContext { TenantId = "tenant-B", CorrelationId = "job-2" };
|
||||
jobQueue.Enqueue(TelemetryContextJobScope.CaptureForJob(accessor)!);
|
||||
|
||||
accessor.Context = null;
|
||||
|
||||
// Consumer: process jobs and verify context
|
||||
var tasks = new List<Task>();
|
||||
while (jobQueue.TryDequeue(out var payload))
|
||||
{
|
||||
var capturedPayload = payload;
|
||||
tasks.Add(Task.Run(() =>
|
||||
{
|
||||
var workerAccessor = new TelemetryContextAccessor();
|
||||
using (TelemetryContextJobScope.ResumeFromJob(workerAccessor, capturedPayload))
|
||||
{
|
||||
var ctx = workerAccessor.Context;
|
||||
if (ctx is not null)
|
||||
{
|
||||
results[ctx.CorrelationId!] = ctx.TenantId;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal("tenant-A", results["job-1"]);
|
||||
Assert.Equal("tenant-B", results["job-2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_IsolatedBetween_ConcurrentJobWorkers()
|
||||
{
|
||||
var workerResults = new ConcurrentDictionary<int, (string? TenantId, string? CorrelationId)>();
|
||||
var barrier = new Barrier(3);
|
||||
|
||||
var tasks = Enumerable.Range(1, 3).Select(workerId =>
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var context = new TelemetryContext
|
||||
{
|
||||
TenantId = $"tenant-{workerId}",
|
||||
CorrelationId = $"corr-{workerId}"
|
||||
};
|
||||
|
||||
using (accessor.CreateScope(context))
|
||||
{
|
||||
// Synchronize all workers to execute simultaneously
|
||||
barrier.SignalAndWait();
|
||||
|
||||
// Simulate some work
|
||||
Thread.Sleep(50);
|
||||
|
||||
// Capture what this worker sees
|
||||
var currentContext = accessor.Context;
|
||||
workerResults[workerId] = (currentContext?.TenantId, currentContext?.CorrelationId);
|
||||
}
|
||||
});
|
||||
}).ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Each worker should see its own context, not another's
|
||||
Assert.Equal(("tenant-1", "corr-1"), workerResults[1]);
|
||||
Assert.Equal(("tenant-2", "corr-2"), workerResults[2]);
|
||||
Assert.Equal(("tenant-3", "corr-3"), workerResults[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_FlowsThrough_NestedAsyncOperations()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var capturedTenants = new List<string?>();
|
||||
|
||||
async Task NestedOperation(int depth)
|
||||
{
|
||||
capturedTenants.Add(accessor.Context?.TenantId);
|
||||
|
||||
if (depth > 0)
|
||||
{
|
||||
await Task.Delay(10);
|
||||
await NestedOperation(depth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
using (accessor.CreateScope(new TelemetryContext { TenantId = "nested-tenant" }))
|
||||
{
|
||||
await NestedOperation(3);
|
||||
}
|
||||
|
||||
// All captures should show the same tenant
|
||||
Assert.All(capturedTenants, t => Assert.Equal("nested-tenant", t));
|
||||
Assert.Equal(4, capturedTenants.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_Preserved_AcrossConfigureAwait()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
string? capturedBefore = null;
|
||||
string? capturedAfter = null;
|
||||
|
||||
using (accessor.CreateScope(new TelemetryContext { TenantId = "await-test" }))
|
||||
{
|
||||
capturedBefore = accessor.Context?.TenantId;
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
capturedAfter = accessor.Context?.TenantId;
|
||||
}
|
||||
|
||||
Assert.Equal("await-test", capturedBefore);
|
||||
Assert.Equal("await-test", capturedAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContextInjector_Inject_AddsAllHeaders()
|
||||
{
|
||||
var context = new TelemetryContext
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
Actor = "user@example.com",
|
||||
CorrelationId = "corr-456",
|
||||
ImposedRule = "rule-789"
|
||||
};
|
||||
|
||||
var headers = new Dictionary<string, string>();
|
||||
TelemetryContextInjector.Inject(context, headers);
|
||||
|
||||
Assert.Equal("tenant-123", headers["X-Tenant-Id"]);
|
||||
Assert.Equal("user@example.com", headers["X-Actor"]);
|
||||
Assert.Equal("corr-456", headers["X-Correlation-Id"]);
|
||||
Assert.Equal("rule-789", headers["X-Imposed-Rule"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContextInjector_Extract_ReconstructsContext()
|
||||
{
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
["X-Tenant-Id"] = "tenant-123",
|
||||
["X-Actor"] = "user@example.com",
|
||||
["X-Correlation-Id"] = "corr-456",
|
||||
["X-Imposed-Rule"] = "rule-789"
|
||||
};
|
||||
|
||||
var context = TelemetryContextInjector.Extract(headers);
|
||||
|
||||
Assert.Equal("tenant-123", context.TenantId);
|
||||
Assert.Equal("user@example.com", context.Actor);
|
||||
Assert.Equal("corr-456", context.CorrelationId);
|
||||
Assert.Equal("rule-789", context.ImposedRule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContextInjector_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
var original = new TelemetryContext
|
||||
{
|
||||
TenantId = "roundtrip-tenant",
|
||||
Actor = "roundtrip-actor",
|
||||
CorrelationId = "roundtrip-corr",
|
||||
ImposedRule = "roundtrip-rule"
|
||||
};
|
||||
|
||||
var headers = new Dictionary<string, string>();
|
||||
TelemetryContextInjector.Inject(original, headers);
|
||||
var restored = TelemetryContextInjector.Extract(headers);
|
||||
|
||||
Assert.Equal(original.TenantId, restored.TenantId);
|
||||
Assert.Equal(original.Actor, restored.Actor);
|
||||
Assert.Equal(original.CorrelationId, restored.CorrelationId);
|
||||
Assert.Equal(original.ImposedRule, restored.ImposedRule);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class CliTelemetryContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseTelemetryArgs_ExtractsTenantId_EqualsSyntax()
|
||||
{
|
||||
var args = new[] { "--tenant-id=my-tenant", "--other-arg", "value" };
|
||||
|
||||
var result = CliTelemetryContext.ParseTelemetryArgs(args);
|
||||
|
||||
Assert.Equal("my-tenant", result["tenant-id"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTelemetryArgs_ExtractsTenantId_SpaceSyntax()
|
||||
{
|
||||
var args = new[] { "--tenant-id", "my-tenant", "--other-arg", "value" };
|
||||
|
||||
var result = CliTelemetryContext.ParseTelemetryArgs(args);
|
||||
|
||||
Assert.Equal("my-tenant", result["tenant-id"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTelemetryArgs_ExtractsActor()
|
||||
{
|
||||
var args = new[] { "--actor=user@example.com" };
|
||||
|
||||
var result = CliTelemetryContext.ParseTelemetryArgs(args);
|
||||
|
||||
Assert.Equal("user@example.com", result["actor"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTelemetryArgs_ExtractsCorrelationId()
|
||||
{
|
||||
var args = new[] { "--correlation-id", "corr-123" };
|
||||
|
||||
var result = CliTelemetryContext.ParseTelemetryArgs(args);
|
||||
|
||||
Assert.Equal("corr-123", result["correlation-id"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTelemetryArgs_ExtractsImposedRule()
|
||||
{
|
||||
var args = new[] { "--imposed-rule=policy-abc" };
|
||||
|
||||
var result = CliTelemetryContext.ParseTelemetryArgs(args);
|
||||
|
||||
Assert.Equal("policy-abc", result["imposed-rule"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTelemetryArgs_ExtractsMultipleArgs()
|
||||
{
|
||||
var args = new[]
|
||||
{
|
||||
"--tenant-id", "tenant-123",
|
||||
"--actor=user@example.com",
|
||||
"--correlation-id=corr-456",
|
||||
"--imposed-rule", "rule-789",
|
||||
"--other-flag"
|
||||
};
|
||||
|
||||
var result = CliTelemetryContext.ParseTelemetryArgs(args);
|
||||
|
||||
Assert.Equal("tenant-123", result["tenant-id"]);
|
||||
Assert.Equal("user@example.com", result["actor"]);
|
||||
Assert.Equal("corr-456", result["correlation-id"]);
|
||||
Assert.Equal("rule-789", result["imposed-rule"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTelemetryArgs_IgnoresUnknownArgs()
|
||||
{
|
||||
var args = new[] { "--unknown-arg", "value", "--another", "thing" };
|
||||
|
||||
var result = CliTelemetryContext.ParseTelemetryArgs(args);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseTelemetryArgs_CaseInsensitive()
|
||||
{
|
||||
var args = new[] { "--TENANT-ID=upper", "--Actor=mixed" };
|
||||
|
||||
var result = CliTelemetryContext.ParseTelemetryArgs(args);
|
||||
|
||||
Assert.Equal("upper", result["tenant-id"]);
|
||||
Assert.Equal("mixed", result["actor"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_SetsContextFromExplicitValues()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
|
||||
using (CliTelemetryContext.Initialize(
|
||||
accessor,
|
||||
tenantId: "explicit-tenant",
|
||||
actor: "explicit-actor",
|
||||
correlationId: "explicit-corr",
|
||||
imposedRule: "explicit-rule"))
|
||||
{
|
||||
var context = accessor.Context;
|
||||
Assert.NotNull(context);
|
||||
Assert.Equal("explicit-tenant", context.TenantId);
|
||||
Assert.Equal("explicit-actor", context.Actor);
|
||||
Assert.Equal("explicit-corr", context.CorrelationId);
|
||||
Assert.Equal("explicit-rule", context.ImposedRule);
|
||||
}
|
||||
|
||||
Assert.Null(accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_GeneratesCorrelationId_WhenNotProvided()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
|
||||
using (CliTelemetryContext.Initialize(accessor, tenantId: "tenant"))
|
||||
{
|
||||
var context = accessor.Context;
|
||||
Assert.NotNull(context);
|
||||
Assert.NotNull(context.CorrelationId);
|
||||
Assert.NotEmpty(context.CorrelationId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializeFromArgs_UsesParseOutput()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var args = new Dictionary<string, string?>
|
||||
{
|
||||
["tenant-id"] = "dict-tenant",
|
||||
["actor"] = "dict-actor"
|
||||
};
|
||||
|
||||
using (CliTelemetryContext.InitializeFromArgs(accessor, args))
|
||||
{
|
||||
var context = accessor.Context;
|
||||
Assert.NotNull(context);
|
||||
Assert.Equal("dict-tenant", context.TenantId);
|
||||
Assert.Equal("dict-actor", context.Actor);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_ClearsContext_OnScopeDisposal()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
|
||||
var scope = CliTelemetryContext.Initialize(accessor, tenantId: "scoped");
|
||||
Assert.NotNull(accessor.Context);
|
||||
|
||||
scope.Dispose();
|
||||
Assert.Null(accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitializeFromEnvironment_ReadsEnvVars()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
|
||||
// Set environment variables
|
||||
var originalTenant = Environment.GetEnvironmentVariable(CliTelemetryContext.TenantIdEnvVar);
|
||||
var originalActor = Environment.GetEnvironmentVariable(CliTelemetryContext.ActorEnvVar);
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(CliTelemetryContext.TenantIdEnvVar, "env-tenant");
|
||||
Environment.SetEnvironmentVariable(CliTelemetryContext.ActorEnvVar, "env-actor");
|
||||
|
||||
using (CliTelemetryContext.InitializeFromEnvironment(accessor))
|
||||
{
|
||||
var context = accessor.Context;
|
||||
Assert.NotNull(context);
|
||||
Assert.Equal("env-tenant", context.TenantId);
|
||||
Assert.Equal("env-actor", context.Actor);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore original values
|
||||
Environment.SetEnvironmentVariable(CliTelemetryContext.TenantIdEnvVar, originalTenant);
|
||||
Environment.SetEnvironmentVariable(CliTelemetryContext.ActorEnvVar, originalActor);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_ExplicitValues_OverrideEnvironment()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
|
||||
var originalTenant = Environment.GetEnvironmentVariable(CliTelemetryContext.TenantIdEnvVar);
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(CliTelemetryContext.TenantIdEnvVar, "env-tenant");
|
||||
|
||||
using (CliTelemetryContext.Initialize(accessor, tenantId: "explicit-tenant"))
|
||||
{
|
||||
var context = accessor.Context;
|
||||
Assert.NotNull(context);
|
||||
Assert.Equal("explicit-tenant", context.TenantId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(CliTelemetryContext.TenantIdEnvVar, originalTenant);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class DeterministicLogFormatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizeTimestamp_ConvertsToUtc()
|
||||
{
|
||||
var localTime = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.FromHours(5));
|
||||
|
||||
var result = DeterministicLogFormatter.NormalizeTimestamp(localTime);
|
||||
|
||||
Assert.Equal("2025-06-15T09:30:45.123Z", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeTimestamp_TruncatesSubmilliseconds()
|
||||
{
|
||||
var timestamp1 = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.Zero).AddTicks(1234);
|
||||
var timestamp2 = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.Zero).AddTicks(9999);
|
||||
|
||||
var result1 = DeterministicLogFormatter.NormalizeTimestamp(timestamp1);
|
||||
var result2 = DeterministicLogFormatter.NormalizeTimestamp(timestamp2);
|
||||
|
||||
Assert.Equal(result1, result2);
|
||||
Assert.Equal("2025-06-15T14:30:45.123Z", result1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeTimestamp_DateTime_HandledCorrectly()
|
||||
{
|
||||
var dateTime = new DateTime(2025, 6, 15, 14, 30, 45, 123, DateTimeKind.Utc);
|
||||
|
||||
var result = DeterministicLogFormatter.NormalizeTimestamp(dateTime);
|
||||
|
||||
Assert.Equal("2025-06-15T14:30:45.123Z", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderFields_ReservedFieldsFirst()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("custom_field", "value"),
|
||||
new("message", "test message"),
|
||||
new("level", "Info"),
|
||||
new("timestamp", "2025-06-15T14:30:45.123Z")
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.OrderFields(fields).ToList();
|
||||
|
||||
Assert.Equal("timestamp", result[0].Key);
|
||||
Assert.Equal("level", result[1].Key);
|
||||
Assert.Equal("message", result[2].Key);
|
||||
Assert.Equal("custom_field", result[3].Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderFields_RemainingFieldsSortedAlphabetically()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("zebra", "last"),
|
||||
new("alpha", "first"),
|
||||
new("middle", "between"),
|
||||
new("message", "preserved")
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.OrderFields(fields).ToList();
|
||||
|
||||
// Reserved field first
|
||||
Assert.Equal("message", result[0].Key);
|
||||
// Remaining sorted alphabetically
|
||||
Assert.Equal("alpha", result[1].Key);
|
||||
Assert.Equal("middle", result[2].Key);
|
||||
Assert.Equal("zebra", result[3].Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderFields_CaseInsensitiveSorting()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("Zebra", "upper"),
|
||||
new("apple", "lower"),
|
||||
new("Banana", "upper"),
|
||||
new("cherry", "lower")
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.OrderFields(fields).ToList();
|
||||
|
||||
Assert.Equal("apple", result[0].Key);
|
||||
Assert.Equal("Banana", result[1].Key);
|
||||
Assert.Equal("cherry", result[2].Key);
|
||||
Assert.Equal("Zebra", result[3].Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderFields_DeterministicWithSameInput()
|
||||
{
|
||||
var fields1 = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("c", "3"),
|
||||
new("a", "1"),
|
||||
new("message", "msg"),
|
||||
new("b", "2")
|
||||
};
|
||||
|
||||
var fields2 = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("b", "2"),
|
||||
new("message", "msg"),
|
||||
new("c", "3"),
|
||||
new("a", "1")
|
||||
};
|
||||
|
||||
var result1 = DeterministicLogFormatter.OrderFields(fields1).Select(x => x.Key).ToList();
|
||||
var result2 = DeterministicLogFormatter.OrderFields(fields2).Select(x => x.Key).ToList();
|
||||
|
||||
Assert.Equal(result1, result2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsNdJson_FieldsInDeterministicOrder()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("custom", "value"),
|
||||
new("message", "test"),
|
||||
new("level", "Info")
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsNdJson(fields);
|
||||
|
||||
// Verify level comes before message comes before custom
|
||||
var levelIndex = result.IndexOf("\"level\"");
|
||||
var messageIndex = result.IndexOf("\"message\"");
|
||||
var customIndex = result.IndexOf("\"custom\"");
|
||||
|
||||
Assert.True(levelIndex < messageIndex);
|
||||
Assert.True(messageIndex < customIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsNdJson_WithTimestamp_NormalizesTimestamp()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("message", "test")
|
||||
};
|
||||
var timestamp = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.FromHours(5));
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsNdJson(fields, timestamp);
|
||||
|
||||
Assert.Contains("\"timestamp\":\"2025-06-15T09:30:45.123Z\"", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsNdJson_ReplacesExistingTimestamp()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("timestamp", "old-value"),
|
||||
new("message", "test")
|
||||
};
|
||||
var timestamp = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.Zero);
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsNdJson(fields, timestamp);
|
||||
|
||||
Assert.DoesNotContain("old-value", result);
|
||||
Assert.Contains("2025-06-15T14:30:45.123Z", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsNdJson_NullValues_Excluded()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("message", "test"),
|
||||
new("null_field", null)
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsNdJson(fields);
|
||||
|
||||
Assert.DoesNotContain("null_field", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsKeyValue_FieldsInDeterministicOrder()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("custom", "value"),
|
||||
new("message", "test"),
|
||||
new("level", "Info")
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsKeyValue(fields);
|
||||
|
||||
var levelIndex = result.IndexOf("level=");
|
||||
var messageIndex = result.IndexOf("message=");
|
||||
var customIndex = result.IndexOf("custom=");
|
||||
|
||||
Assert.True(levelIndex < messageIndex);
|
||||
Assert.True(messageIndex < customIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsKeyValue_QuotesStringsWithSpaces()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("message", "test with spaces"),
|
||||
new("simple", "nospace")
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsKeyValue(fields);
|
||||
|
||||
Assert.Contains("message=\"test with spaces\"", result);
|
||||
Assert.Contains("simple=nospace", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsKeyValue_EscapesQuotesInValues()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("message", "value with \"quotes\"")
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsKeyValue(fields);
|
||||
|
||||
Assert.Contains("\\\"quotes\\\"", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsKeyValue_WithTimestamp_NormalizesTimestamp()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("message", "test")
|
||||
};
|
||||
var timestamp = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.FromHours(5));
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsKeyValue(fields, timestamp);
|
||||
|
||||
Assert.Contains("timestamp=2025-06-15T09:30:45.123Z", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatAsKeyValue_NullValues_ShownAsNull()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("message", "test"),
|
||||
new("null_field", null)
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsKeyValue(fields);
|
||||
|
||||
Assert.Contains("null_field=null", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedFormatting_ProducesSameOutput()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("trace_id", "abc123"),
|
||||
new("message", "test message"),
|
||||
new("level", "Info"),
|
||||
new("custom_a", "value_a"),
|
||||
new("custom_b", "value_b")
|
||||
};
|
||||
var timestamp = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.Zero);
|
||||
|
||||
var results = Enumerable.Range(0, 10)
|
||||
.Select(_ => DeterministicLogFormatter.FormatAsNdJson(fields, timestamp))
|
||||
.ToList();
|
||||
|
||||
Assert.All(results, r => Assert.Equal(results[0], r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedKeyValueFormatting_ProducesSameOutput()
|
||||
{
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("trace_id", "abc123"),
|
||||
new("message", "test"),
|
||||
new("level", "Info"),
|
||||
new("custom", "value")
|
||||
};
|
||||
var timestamp = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.Zero);
|
||||
|
||||
var results = Enumerable.Range(0, 10)
|
||||
.Select(_ => DeterministicLogFormatter.FormatAsKeyValue(fields, timestamp))
|
||||
.ToList();
|
||||
|
||||
Assert.All(results, r => Assert.Equal(results[0], r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTimeOffsetValuesInFields_NormalizedToUtc()
|
||||
{
|
||||
var localTimestamp = new DateTimeOffset(2025, 6, 15, 14, 30, 45, 123, TimeSpan.FromHours(5));
|
||||
var fields = new List<KeyValuePair<string, object?>>
|
||||
{
|
||||
new("event_time", localTimestamp)
|
||||
};
|
||||
|
||||
var result = DeterministicLogFormatter.FormatAsNdJson(fields);
|
||||
|
||||
Assert.Contains("2025-06-15T09:30:45.123Z", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReservedFieldOrder_MatchesSpecification()
|
||||
{
|
||||
var expectedOrder = new[]
|
||||
{
|
||||
"timestamp", "level", "message", "trace_id", "span_id",
|
||||
"tenant_id", "actor", "correlation_id", "service_name", "service_version"
|
||||
};
|
||||
|
||||
Assert.Equal(expectedOrder, DeterministicLogFormatter.ReservedFieldOrder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class GoldenSignalMetricsTests : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<(string Name, object Value)> _recordedMeasurements;
|
||||
|
||||
public GoldenSignalMetricsTests()
|
||||
{
|
||||
_recordedMeasurements = new List<(string Name, object Value)>();
|
||||
_listener = new MeterListener();
|
||||
_listener.InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == GoldenSignalMetrics.MeterName)
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
_recordedMeasurements.Add((instrument.Name, measurement));
|
||||
});
|
||||
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
_recordedMeasurements.Add((instrument.Name, measurement));
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_listener.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordLatency_RecordsMeasurement()
|
||||
{
|
||||
using var metrics = new GoldenSignalMetrics();
|
||||
|
||||
metrics.RecordLatency(0.123);
|
||||
|
||||
Assert.Contains(_recordedMeasurements, m => m.Name == "stellaops_latency_seconds" && (double)m.Value == 0.123);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordLatency_AcceptsStopwatch()
|
||||
{
|
||||
using var metrics = new GoldenSignalMetrics();
|
||||
var sw = Stopwatch.StartNew();
|
||||
sw.Stop();
|
||||
|
||||
metrics.RecordLatency(sw);
|
||||
|
||||
Assert.Contains(_recordedMeasurements, m => m.Name == "stellaops_latency_seconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MeasureLatency_RecordsDurationOnDispose()
|
||||
{
|
||||
using var metrics = new GoldenSignalMetrics();
|
||||
|
||||
using (metrics.MeasureLatency())
|
||||
{
|
||||
System.Threading.Thread.Sleep(10);
|
||||
}
|
||||
|
||||
Assert.Contains(_recordedMeasurements, m =>
|
||||
m.Name == "stellaops_latency_seconds" && (double)m.Value >= 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementErrors_IncreasesCounter()
|
||||
{
|
||||
using var metrics = new GoldenSignalMetrics();
|
||||
|
||||
metrics.IncrementErrors();
|
||||
metrics.IncrementErrors(5);
|
||||
|
||||
Assert.Contains(_recordedMeasurements, m => m.Name == "stellaops_errors_total" && (long)m.Value == 1);
|
||||
Assert.Contains(_recordedMeasurements, m => m.Name == "stellaops_errors_total" && (long)m.Value == 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementRequests_IncreasesCounter()
|
||||
{
|
||||
using var metrics = new GoldenSignalMetrics();
|
||||
|
||||
metrics.IncrementRequests();
|
||||
metrics.IncrementRequests(10);
|
||||
|
||||
Assert.Contains(_recordedMeasurements, m => m.Name == "stellaops_requests_total" && (long)m.Value == 1);
|
||||
Assert.Contains(_recordedMeasurements, m => m.Name == "stellaops_requests_total" && (long)m.Value == 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordLatency_WithTags_Works()
|
||||
{
|
||||
using var metrics = new GoldenSignalMetrics();
|
||||
|
||||
metrics.RecordLatency(0.5,
|
||||
GoldenSignalMetrics.Tag("method", "GET"),
|
||||
GoldenSignalMetrics.Tag("status_code", 200));
|
||||
|
||||
Assert.Contains(_recordedMeasurements, m => m.Name == "stellaops_latency_seconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_PrefixIsApplied()
|
||||
{
|
||||
var options = new GoldenSignalMetricsOptions { Prefix = "custom_" };
|
||||
using var metrics = new GoldenSignalMetrics(options);
|
||||
|
||||
metrics.RecordLatency(0.1);
|
||||
|
||||
Assert.Contains(_recordedMeasurements, m => m.Name == "custom_latency_seconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetSaturationProvider_IsInvoked()
|
||||
{
|
||||
var options = new GoldenSignalMetricsOptions { EnableSaturationGauge = true };
|
||||
using var metrics = new GoldenSignalMetrics(options);
|
||||
var saturationValue = 0.75;
|
||||
|
||||
metrics.SetSaturationProvider(() => saturationValue);
|
||||
|
||||
Assert.NotNull(metrics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CardinalityGuard_WarnsOnHighCardinality()
|
||||
{
|
||||
var logEntries = new List<string>();
|
||||
var loggerProvider = new CollectingLoggerProvider(logEntries);
|
||||
using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(loggerProvider));
|
||||
var logger = loggerFactory.CreateLogger<GoldenSignalMetrics>();
|
||||
|
||||
var options = new GoldenSignalMetricsOptions
|
||||
{
|
||||
MaxCardinalityPerLabel = 5,
|
||||
DropHighCardinalityMetrics = false,
|
||||
};
|
||||
using var metrics = new GoldenSignalMetrics(options, logger);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
metrics.IncrementRequests(1, GoldenSignalMetrics.Tag("unique_id", $"id-{i}"));
|
||||
}
|
||||
|
||||
Assert.Contains(logEntries, e => e.Contains("High cardinality"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CardinalityGuard_DropsMetrics_WhenConfigured()
|
||||
{
|
||||
var options = new GoldenSignalMetricsOptions
|
||||
{
|
||||
MaxCardinalityPerLabel = 2,
|
||||
DropHighCardinalityMetrics = true,
|
||||
};
|
||||
using var metrics = new GoldenSignalMetrics(options);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
metrics.IncrementRequests(1, GoldenSignalMetrics.Tag("unique_id", $"id-{i}"));
|
||||
}
|
||||
|
||||
var requestCount = _recordedMeasurements.Count(m => m.Name == "stellaops_requests_total");
|
||||
Assert.True(requestCount <= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_CreatesKeyValuePair()
|
||||
{
|
||||
var tag = GoldenSignalMetrics.Tag("key", "value");
|
||||
|
||||
Assert.Equal("key", tag.Key);
|
||||
Assert.Equal("value", tag.Value);
|
||||
}
|
||||
|
||||
private sealed class CollectingLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly List<string> _entries;
|
||||
|
||||
public CollectingLoggerProvider(List<string> entries) => _entries = entries;
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new CollectingLogger(_entries);
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class CollectingLogger : ILogger
|
||||
{
|
||||
private readonly List<string> _entries;
|
||||
|
||||
public CollectingLogger(List<string> entries) => _entries = entries;
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull =>
|
||||
new NoOpScope();
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
_entries.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoOpScope : IDisposable
|
||||
{
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class LogRedactorTests
|
||||
{
|
||||
private static LogRedactor CreateRedactor(Action<LogRedactionOptions>? configure = null)
|
||||
{
|
||||
var options = new LogRedactionOptions();
|
||||
configure?.Invoke(options);
|
||||
var monitor = new TestOptionsMonitor<LogRedactionOptions>(options);
|
||||
return new LogRedactor(monitor);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("password")]
|
||||
[InlineData("Password")]
|
||||
[InlineData("PASSWORD")]
|
||||
[InlineData("secret")]
|
||||
[InlineData("apikey")]
|
||||
[InlineData("api_key")]
|
||||
[InlineData("token")]
|
||||
[InlineData("connectionstring")]
|
||||
[InlineData("authorization")]
|
||||
public void IsSensitiveField_DefaultSensitiveFields_ReturnsTrue(string fieldName)
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
|
||||
var result = redactor.IsSensitiveField(fieldName);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("TraceId")]
|
||||
[InlineData("SpanId")]
|
||||
[InlineData("RequestId")]
|
||||
[InlineData("CorrelationId")]
|
||||
public void IsSensitiveField_ExcludedFields_ReturnsFalse(string fieldName)
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
|
||||
var result = redactor.IsSensitiveField(fieldName);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("status")]
|
||||
[InlineData("operation")]
|
||||
[InlineData("duration")]
|
||||
[InlineData("count")]
|
||||
public void IsSensitiveField_RegularFields_ReturnsFalse(string fieldName)
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
|
||||
var result = redactor.IsSensitiveField(fieldName);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_JwtToken_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
|
||||
var input = $"Authorization failed for token {jwt}";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.DoesNotContain(jwt, result);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_BearerToken_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var input = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.DoesNotContain("Bearer eyJ", result);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_EmailAddress_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var input = "User john.doe@example.com logged in";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.DoesNotContain("john.doe@example.com", result);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_CreditCardNumber_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var input = "Payment processed for card 4111111111111111";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.DoesNotContain("4111111111111111", result);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_SSN_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var input = "SSN: 123-45-6789";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.DoesNotContain("123-45-6789", result);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_IPAddress_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var input = "Request from 192.168.1.100";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.DoesNotContain("192.168.1.100", result);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_ConnectionString_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var input = "Server=localhost;Database=test;password=secret123;";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.DoesNotContain("password=secret123", result);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_AWSAccessKey_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var input = "Using key AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.DoesNotContain("AKIAIOSFODNN7EXAMPLE", result);
|
||||
Assert.Contains("[REDACTED]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_NullOrEmpty_ReturnsOriginal()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
|
||||
Assert.Equal("", redactor.RedactString(null));
|
||||
Assert.Equal("", redactor.RedactString(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_NoSensitiveData_ReturnsOriginal()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var input = "This is a normal log message with operation=success";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.Equal(input, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactString_DisabledRedaction_ReturnsOriginal()
|
||||
{
|
||||
var redactor = CreateRedactor(options => options.Enabled = false);
|
||||
var input = "User john.doe@example.com logged in";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.Equal(input, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactAttributes_SensitiveFieldName_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["password"] = "secret123",
|
||||
["username"] = "john",
|
||||
["operation"] = "login"
|
||||
};
|
||||
|
||||
var result = redactor.RedactAttributes(attributes);
|
||||
|
||||
Assert.Equal("[REDACTED]", attributes["password"]);
|
||||
Assert.Equal("john", attributes["username"]);
|
||||
Assert.Equal("login", attributes["operation"]);
|
||||
Assert.Equal(1, result.RedactedFieldCount);
|
||||
Assert.Contains("password", result.RedactedFieldNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactAttributes_PatternInValue_Redacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["user_email"] = "john@example.com",
|
||||
["operation"] = "login"
|
||||
};
|
||||
|
||||
var result = redactor.RedactAttributes(attributes);
|
||||
|
||||
Assert.Equal("[REDACTED]", attributes["user_email"]);
|
||||
Assert.Equal("login", attributes["operation"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactAttributes_EmptyDictionary_ReturnsNone()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var attributes = new Dictionary<string, object?>();
|
||||
|
||||
var result = redactor.RedactAttributes(attributes);
|
||||
|
||||
Assert.Equal(0, result.RedactedFieldCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactAttributes_ExcludedField_NotRedacted()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["TraceId"] = "abc123",
|
||||
["password"] = "secret"
|
||||
};
|
||||
|
||||
redactor.RedactAttributes(attributes);
|
||||
|
||||
Assert.Equal("abc123", attributes["TraceId"]);
|
||||
Assert.Equal("[REDACTED]", attributes["password"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantOverride_AdditionalSensitiveFields_Applied()
|
||||
{
|
||||
var redactor = CreateRedactor(options =>
|
||||
{
|
||||
options.TenantOverrides["tenant-a"] = new TenantRedactionOverride
|
||||
{
|
||||
AdditionalSensitiveFields = { "customer_id", "order_number" }
|
||||
};
|
||||
});
|
||||
|
||||
// Without tenant context
|
||||
Assert.False(redactor.IsSensitiveField("customer_id"));
|
||||
|
||||
// With tenant context
|
||||
Assert.True(redactor.IsSensitiveField("customer_id", "tenant-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantOverride_ExcludedFields_Applied()
|
||||
{
|
||||
var redactor = CreateRedactor(options =>
|
||||
{
|
||||
options.TenantOverrides["tenant-a"] = new TenantRedactionOverride
|
||||
{
|
||||
ExcludedFields = { "password" },
|
||||
OverrideReason = "Special compliance requirement"
|
||||
};
|
||||
});
|
||||
|
||||
// Global context - password is sensitive
|
||||
Assert.True(redactor.IsSensitiveField("password"));
|
||||
|
||||
// Tenant context - password is excluded
|
||||
Assert.False(redactor.IsSensitiveField("password", "tenant-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantOverride_DisableRedaction_Applied()
|
||||
{
|
||||
var redactor = CreateRedactor(options =>
|
||||
{
|
||||
options.TenantOverrides["tenant-a"] = new TenantRedactionOverride
|
||||
{
|
||||
DisableRedaction = true,
|
||||
OverrideReason = "Debug mode"
|
||||
};
|
||||
});
|
||||
|
||||
Assert.True(redactor.IsRedactionEnabled());
|
||||
Assert.False(redactor.IsRedactionEnabled("tenant-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantOverride_ExpiredOverride_NotApplied()
|
||||
{
|
||||
var redactor = CreateRedactor(options =>
|
||||
{
|
||||
options.TenantOverrides["tenant-a"] = new TenantRedactionOverride
|
||||
{
|
||||
DisableRedaction = true,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
};
|
||||
});
|
||||
|
||||
Assert.True(redactor.IsRedactionEnabled("tenant-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantOverride_FutureExpiry_Applied()
|
||||
{
|
||||
var redactor = CreateRedactor(options =>
|
||||
{
|
||||
options.TenantOverrides["tenant-a"] = new TenantRedactionOverride
|
||||
{
|
||||
DisableRedaction = true,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1)
|
||||
};
|
||||
});
|
||||
|
||||
Assert.False(redactor.IsRedactionEnabled("tenant-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactAttributes_TracksMatchedPatterns()
|
||||
{
|
||||
var redactor = CreateRedactor();
|
||||
var attributes = new Dictionary<string, object?>
|
||||
{
|
||||
["auth_header"] = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig",
|
||||
["user_contact"] = "contact@example.com"
|
||||
};
|
||||
|
||||
var result = redactor.RedactAttributes(attributes);
|
||||
|
||||
Assert.Contains("Bearer", result.MatchedPatterns);
|
||||
Assert.Contains("Email", result.MatchedPatterns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomPlaceholder_Used()
|
||||
{
|
||||
var redactor = CreateRedactor(options =>
|
||||
{
|
||||
options.RedactionPlaceholder = "***HIDDEN***";
|
||||
});
|
||||
|
||||
var input = "Email: test@example.com";
|
||||
|
||||
var result = redactor.RedactString(input);
|
||||
|
||||
Assert.Contains("***HIDDEN***", result);
|
||||
Assert.DoesNotContain("[REDACTED]", result);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public TestOptionsMonitor(T value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class TelemetryContextAccessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Context_StartsNull()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
Assert.Null(accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_CanBeSetAndRead()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var context = new TelemetryContext { TenantId = "tenant-123" };
|
||||
|
||||
accessor.Context = context;
|
||||
|
||||
Assert.Same(context, accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_CanBeCleared()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
accessor.Context = new TelemetryContext { TenantId = "tenant-123" };
|
||||
|
||||
accessor.Context = null;
|
||||
|
||||
Assert.Null(accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateScope_SetsContextForDuration()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var scopeContext = new TelemetryContext { TenantId = "scoped-tenant" };
|
||||
|
||||
using (accessor.CreateScope(scopeContext))
|
||||
{
|
||||
Assert.Same(scopeContext, accessor.Context);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateScope_RestoresPreviousContextOnDispose()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var originalContext = new TelemetryContext { TenantId = "original" };
|
||||
var scopeContext = new TelemetryContext { TenantId = "scoped" };
|
||||
|
||||
accessor.Context = originalContext;
|
||||
|
||||
using (accessor.CreateScope(scopeContext))
|
||||
{
|
||||
Assert.Same(scopeContext, accessor.Context);
|
||||
}
|
||||
|
||||
Assert.Same(originalContext, accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateScope_RestoresNull_WhenNoPreviousContext()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var scopeContext = new TelemetryContext { TenantId = "scoped" };
|
||||
|
||||
using (accessor.CreateScope(scopeContext))
|
||||
{
|
||||
Assert.Same(scopeContext, accessor.Context);
|
||||
}
|
||||
|
||||
Assert.Null(accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_FlowsAcrossAsyncBoundaries()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
var context = new TelemetryContext { TenantId = "async-tenant" };
|
||||
accessor.Context = context;
|
||||
|
||||
await Task.Delay(1);
|
||||
|
||||
Assert.Same(context, accessor.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Context_IsIsolatedBetweenAsyncContexts()
|
||||
{
|
||||
var accessor = new TelemetryContextAccessor();
|
||||
|
||||
var task1 = Task.Run(() =>
|
||||
{
|
||||
accessor.Context = new TelemetryContext { TenantId = "tenant-1" };
|
||||
Task.Delay(50).Wait();
|
||||
return accessor.Context?.TenantId;
|
||||
});
|
||||
|
||||
var task2 = Task.Run(() =>
|
||||
{
|
||||
accessor.Context = new TelemetryContext { TenantId = "tenant-2" };
|
||||
Task.Delay(50).Wait();
|
||||
return accessor.Context?.TenantId;
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(task1, task2);
|
||||
|
||||
Assert.Equal("tenant-1", results[0]);
|
||||
Assert.Equal("tenant-2", results[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class TelemetryContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Context_Clone_CopiesAllFields()
|
||||
{
|
||||
var context = new TelemetryContext
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
Actor = "user@example.com",
|
||||
ImposedRule = "rule-456",
|
||||
CorrelationId = "corr-789",
|
||||
};
|
||||
|
||||
var clone = context.Clone();
|
||||
|
||||
Assert.Equal(context.TenantId, clone.TenantId);
|
||||
Assert.Equal(context.Actor, clone.Actor);
|
||||
Assert.Equal(context.ImposedRule, clone.ImposedRule);
|
||||
Assert.Equal(context.CorrelationId, clone.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Context_Clone_IsIndependent()
|
||||
{
|
||||
var context = new TelemetryContext
|
||||
{
|
||||
TenantId = "tenant-123",
|
||||
};
|
||||
|
||||
var clone = context.Clone();
|
||||
clone.TenantId = "different-tenant";
|
||||
|
||||
Assert.Equal("tenant-123", context.TenantId);
|
||||
Assert.Equal("different-tenant", clone.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInitialized_ReturnsTrueWhenTenantIdSet()
|
||||
{
|
||||
var context = new TelemetryContext { TenantId = "tenant-123" };
|
||||
Assert.True(context.IsInitialized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInitialized_ReturnsTrueWhenActorSet()
|
||||
{
|
||||
var context = new TelemetryContext { Actor = "user@example.com" };
|
||||
Assert.True(context.IsInitialized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInitialized_ReturnsTrueWhenCorrelationIdSet()
|
||||
{
|
||||
var context = new TelemetryContext { CorrelationId = "corr-789" };
|
||||
Assert.True(context.IsInitialized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInitialized_ReturnsFalseWhenEmpty()
|
||||
{
|
||||
var context = new TelemetryContext();
|
||||
Assert.False(context.IsInitialized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TraceId_ReturnsActivityTraceId_WhenActivityExists()
|
||||
{
|
||||
using var activity = new Activity("test-operation");
|
||||
activity.Start();
|
||||
|
||||
var context = new TelemetryContext();
|
||||
|
||||
Assert.Equal(activity.TraceId.ToString(), context.TraceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TraceId_ReturnsEmpty_WhenNoActivity()
|
||||
{
|
||||
Activity.Current = null;
|
||||
var context = new TelemetryContext();
|
||||
|
||||
Assert.Equal(string.Empty, context.TraceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides utilities for initializing telemetry context in CLI applications.
|
||||
/// </summary>
|
||||
public static class CliTelemetryContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment variable name for tenant ID.
|
||||
/// </summary>
|
||||
public const string TenantIdEnvVar = "STELLAOPS_TENANT_ID";
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable name for actor.
|
||||
/// </summary>
|
||||
public const string ActorEnvVar = "STELLAOPS_ACTOR";
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable name for correlation ID.
|
||||
/// </summary>
|
||||
public const string CorrelationIdEnvVar = "STELLAOPS_CORRELATION_ID";
|
||||
|
||||
/// <summary>
|
||||
/// Environment variable name for imposed rule.
|
||||
/// </summary>
|
||||
public const string ImposedRuleEnvVar = "STELLAOPS_IMPOSED_RULE";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes telemetry context from environment variables.
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The context accessor to initialize.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
/// <returns>A disposable scope that clears the context on disposal.</returns>
|
||||
public static IDisposable InitializeFromEnvironment(
|
||||
TelemetryContextAccessor contextAccessor,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contextAccessor);
|
||||
|
||||
var context = new TelemetryContext
|
||||
{
|
||||
TenantId = Environment.GetEnvironmentVariable(TenantIdEnvVar),
|
||||
Actor = Environment.GetEnvironmentVariable(ActorEnvVar),
|
||||
ImposedRule = Environment.GetEnvironmentVariable(ImposedRuleEnvVar),
|
||||
CorrelationId = Environment.GetEnvironmentVariable(CorrelationIdEnvVar)
|
||||
?? Activity.Current?.TraceId.ToString()
|
||||
?? Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
|
||||
logger?.LogDebug(
|
||||
"CLI telemetry context initialized from environment: TenantId={TenantId}, Actor={Actor}, CorrelationId={CorrelationId}",
|
||||
context.TenantId ?? "(none)",
|
||||
context.Actor ?? "(none)",
|
||||
context.CorrelationId);
|
||||
|
||||
return contextAccessor.CreateScope(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes telemetry context from explicit values, with environment variable fallbacks.
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The context accessor to initialize.</param>
|
||||
/// <param name="tenantId">Optional tenant ID (falls back to environment).</param>
|
||||
/// <param name="actor">Optional actor (falls back to environment).</param>
|
||||
/// <param name="correlationId">Optional correlation ID (falls back to environment, then auto-generated).</param>
|
||||
/// <param name="imposedRule">Optional imposed rule (falls back to environment).</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
/// <returns>A disposable scope that clears the context on disposal.</returns>
|
||||
public static IDisposable Initialize(
|
||||
TelemetryContextAccessor contextAccessor,
|
||||
string? tenantId = null,
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
string? imposedRule = null,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contextAccessor);
|
||||
|
||||
var context = new TelemetryContext
|
||||
{
|
||||
TenantId = tenantId ?? Environment.GetEnvironmentVariable(TenantIdEnvVar),
|
||||
Actor = actor ?? Environment.GetEnvironmentVariable(ActorEnvVar),
|
||||
ImposedRule = imposedRule ?? Environment.GetEnvironmentVariable(ImposedRuleEnvVar),
|
||||
CorrelationId = correlationId
|
||||
?? Environment.GetEnvironmentVariable(CorrelationIdEnvVar)
|
||||
?? Activity.Current?.TraceId.ToString()
|
||||
?? Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
|
||||
logger?.LogDebug(
|
||||
"CLI telemetry context initialized: TenantId={TenantId}, Actor={Actor}, CorrelationId={CorrelationId}",
|
||||
context.TenantId ?? "(none)",
|
||||
context.Actor ?? "(none)",
|
||||
context.CorrelationId);
|
||||
|
||||
EnrichCurrentActivity(context);
|
||||
|
||||
return contextAccessor.CreateScope(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a telemetry context from command-line arguments parsed into a dictionary.
|
||||
/// Recognizes: --tenant-id, --actor, --correlation-id, --imposed-rule
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The context accessor to initialize.</param>
|
||||
/// <param name="args">Parsed command-line arguments as key-value pairs.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
/// <returns>A disposable scope that clears the context on disposal.</returns>
|
||||
public static IDisposable InitializeFromArgs(
|
||||
TelemetryContextAccessor contextAccessor,
|
||||
IDictionary<string, string?> args,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contextAccessor);
|
||||
ArgumentNullException.ThrowIfNull(args);
|
||||
|
||||
args.TryGetValue("tenant-id", out var tenantId);
|
||||
args.TryGetValue("actor", out var actor);
|
||||
args.TryGetValue("correlation-id", out var correlationId);
|
||||
args.TryGetValue("imposed-rule", out var imposedRule);
|
||||
|
||||
return Initialize(contextAccessor, tenantId, actor, correlationId, imposedRule, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses standard telemetry arguments from command-line args array.
|
||||
/// Extracts: --tenant-id, --actor, --correlation-id, --imposed-rule
|
||||
/// </summary>
|
||||
/// <param name="args">Raw command-line arguments.</param>
|
||||
/// <returns>Dictionary of parsed telemetry arguments.</returns>
|
||||
public static Dictionary<string, string?> ParseTelemetryArgs(string[] args)
|
||||
{
|
||||
var result = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
string? key = null;
|
||||
|
||||
if (arg.StartsWith("--tenant-id", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
key = "tenant-id";
|
||||
}
|
||||
else if (arg.StartsWith("--actor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
key = "actor";
|
||||
}
|
||||
else if (arg.StartsWith("--correlation-id", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
key = "correlation-id";
|
||||
}
|
||||
else if (arg.StartsWith("--imposed-rule", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
key = "imposed-rule";
|
||||
}
|
||||
|
||||
if (key is null) continue;
|
||||
|
||||
// Handle --key=value format
|
||||
var eqIndex = arg.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
result[key] = arg[(eqIndex + 1)..];
|
||||
}
|
||||
// Handle --key value format
|
||||
else if (i + 1 < args.Length && !args[i + 1].StartsWith('-'))
|
||||
{
|
||||
result[key] = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EnrichCurrentActivity(TelemetryContext context)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is null) return;
|
||||
|
||||
if (!string.IsNullOrEmpty(context.TenantId))
|
||||
{
|
||||
activity.SetTag("tenant.id", context.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Actor))
|
||||
{
|
||||
activity.SetTag("actor.id", context.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.ImposedRule))
|
||||
{
|
||||
activity.SetTag("imposed.rule", context.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CorrelationId))
|
||||
{
|
||||
activity.SetTag("correlation.id", context.CorrelationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic formatting for log output, ensuring stable field ordering
|
||||
/// and timestamp normalization for reproducible log output.
|
||||
/// </summary>
|
||||
public static class DeterministicLogFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// The fixed timestamp format used for deterministic output.
|
||||
/// </summary>
|
||||
public const string TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
|
||||
/// <summary>
|
||||
/// Reserved field names that appear at the start of log entries in a fixed order.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> ReservedFieldOrder = new[]
|
||||
{
|
||||
"timestamp",
|
||||
"level",
|
||||
"message",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"tenant_id",
|
||||
"actor",
|
||||
"correlation_id",
|
||||
"service_name",
|
||||
"service_version"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a timestamp to UTC with truncated milliseconds for deterministic output.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The timestamp to normalize.</param>
|
||||
/// <returns>The normalized timestamp string.</returns>
|
||||
public static string NormalizeTimestamp(DateTimeOffset timestamp)
|
||||
{
|
||||
// Truncate to milliseconds and ensure UTC
|
||||
var utc = timestamp.ToUniversalTime();
|
||||
var truncated = new DateTimeOffset(
|
||||
utc.Year, utc.Month, utc.Day,
|
||||
utc.Hour, utc.Minute, utc.Second, utc.Millisecond,
|
||||
TimeSpan.Zero);
|
||||
return truncated.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a timestamp to UTC with truncated milliseconds for deterministic output.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The timestamp to normalize.</param>
|
||||
/// <returns>The normalized timestamp string.</returns>
|
||||
public static string NormalizeTimestamp(DateTime timestamp)
|
||||
{
|
||||
return NormalizeTimestamp(new DateTimeOffset(timestamp, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders log fields deterministically: reserved fields first in fixed order,
|
||||
/// then remaining fields sorted alphabetically.
|
||||
/// </summary>
|
||||
/// <param name="fields">The fields to order.</param>
|
||||
/// <returns>The fields in deterministic order.</returns>
|
||||
public static IEnumerable<KeyValuePair<string, object?>> OrderFields(
|
||||
IEnumerable<KeyValuePair<string, object?>> fields)
|
||||
{
|
||||
var fieldList = fields.ToList();
|
||||
var result = new List<KeyValuePair<string, object?>>();
|
||||
var remaining = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Build lookup
|
||||
foreach (var kvp in fieldList)
|
||||
{
|
||||
remaining[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
// Add reserved fields in fixed order
|
||||
foreach (var reservedKey in ReservedFieldOrder)
|
||||
{
|
||||
if (remaining.TryGetValue(reservedKey, out var value))
|
||||
{
|
||||
result.Add(new KeyValuePair<string, object?>(reservedKey, value));
|
||||
remaining.Remove(reservedKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining fields in alphabetical order (case-insensitive)
|
||||
var sortedRemaining = remaining
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
result.AddRange(sortedRemaining);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a log entry as a deterministic JSON line (NDJSON format).
|
||||
/// </summary>
|
||||
/// <param name="fields">The log fields.</param>
|
||||
/// <param name="timestamp">Optional timestamp to normalize.</param>
|
||||
/// <returns>The formatted JSON line.</returns>
|
||||
public static string FormatAsNdJson(
|
||||
IEnumerable<KeyValuePair<string, object?>> fields,
|
||||
DateTimeOffset? timestamp = null)
|
||||
{
|
||||
var orderedFields = OrderFields(fields).ToList();
|
||||
|
||||
// Ensure timestamp is normalized
|
||||
if (timestamp.HasValue)
|
||||
{
|
||||
var normalizedTimestamp = NormalizeTimestamp(timestamp.Value);
|
||||
var existingIndex = orderedFields.FindIndex(
|
||||
kvp => string.Equals(kvp.Key, "timestamp", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
orderedFields[existingIndex] = new KeyValuePair<string, object?>("timestamp", normalizedTimestamp);
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedFields.Insert(0, new KeyValuePair<string, object?>("timestamp", normalizedTimestamp));
|
||||
}
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var kvp in orderedFields)
|
||||
{
|
||||
dict[kvp.Key] = NormalizeValue(kvp.Value);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a log entry as a deterministic key=value format.
|
||||
/// </summary>
|
||||
/// <param name="fields">The log fields.</param>
|
||||
/// <param name="timestamp">Optional timestamp to normalize.</param>
|
||||
/// <returns>The formatted log line.</returns>
|
||||
public static string FormatAsKeyValue(
|
||||
IEnumerable<KeyValuePair<string, object?>> fields,
|
||||
DateTimeOffset? timestamp = null)
|
||||
{
|
||||
var orderedFields = OrderFields(fields).ToList();
|
||||
|
||||
// Ensure timestamp is normalized
|
||||
if (timestamp.HasValue)
|
||||
{
|
||||
var normalizedTimestamp = NormalizeTimestamp(timestamp.Value);
|
||||
var existingIndex = orderedFields.FindIndex(
|
||||
kvp => string.Equals(kvp.Key, "timestamp", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
orderedFields[existingIndex] = new KeyValuePair<string, object?>("timestamp", normalizedTimestamp);
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedFields.Insert(0, new KeyValuePair<string, object?>("timestamp", normalizedTimestamp));
|
||||
}
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var first = true;
|
||||
|
||||
foreach (var kvp in orderedFields)
|
||||
{
|
||||
if (!first)
|
||||
{
|
||||
sb.Append(' ');
|
||||
}
|
||||
|
||||
first = false;
|
||||
sb.Append(kvp.Key);
|
||||
sb.Append('=');
|
||||
sb.Append(FormatValue(kvp.Value));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static object? NormalizeValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
DateTimeOffset dto => NormalizeTimestamp(dto),
|
||||
DateTime dt => NormalizeTimestamp(dt),
|
||||
_ => value
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatValue(object? value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (value is string s)
|
||||
{
|
||||
// Quote strings that contain spaces
|
||||
if (s.Contains(' ') || s.Contains('"') || s.Contains('='))
|
||||
{
|
||||
return $"\"{s.Replace("\"", "\\\"")}\"";
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
if (value is DateTimeOffset dto)
|
||||
{
|
||||
return NormalizeTimestamp(dto);
|
||||
}
|
||||
|
||||
if (value is DateTime dt)
|
||||
{
|
||||
return NormalizeTimestamp(dt);
|
||||
}
|
||||
|
||||
return value.ToString() ?? "null";
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions DeterministicJsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null, // Preserve exact key names
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides golden signal metrics (latency, errors, traffic, saturation) with
|
||||
/// cardinality guards and exemplar support.
|
||||
/// </summary>
|
||||
public sealed class GoldenSignalMetrics : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Default meter name for golden signal metrics.
|
||||
/// </summary>
|
||||
public const string MeterName = "StellaOps.GoldenSignals";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly ILogger<GoldenSignalMetrics>? _logger;
|
||||
private readonly GoldenSignalMetricsOptions _options;
|
||||
private readonly ConcurrentDictionary<string, int> _labelCounts;
|
||||
private bool _disposed;
|
||||
|
||||
private readonly Histogram<double> _latencyHistogram;
|
||||
private readonly Counter<long> _errorCounter;
|
||||
private readonly Counter<long> _requestCounter;
|
||||
private readonly ObservableGauge<double>? _saturationGauge;
|
||||
private Func<double>? _saturationProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GoldenSignalMetrics"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">Configuration options.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
public GoldenSignalMetrics(GoldenSignalMetricsOptions? options = null, ILogger<GoldenSignalMetrics>? logger = null)
|
||||
{
|
||||
_options = options ?? new GoldenSignalMetricsOptions();
|
||||
_logger = logger;
|
||||
_labelCounts = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_meter = new Meter(MeterName, _options.Version);
|
||||
|
||||
_latencyHistogram = _meter.CreateHistogram<double>(
|
||||
name: $"{_options.Prefix}latency_seconds",
|
||||
unit: "s",
|
||||
description: "Request latency in seconds.");
|
||||
|
||||
_errorCounter = _meter.CreateCounter<long>(
|
||||
name: $"{_options.Prefix}errors_total",
|
||||
unit: "{error}",
|
||||
description: "Total number of errors.");
|
||||
|
||||
_requestCounter = _meter.CreateCounter<long>(
|
||||
name: $"{_options.Prefix}requests_total",
|
||||
unit: "{request}",
|
||||
description: "Total number of requests.");
|
||||
|
||||
if (_options.EnableSaturationGauge)
|
||||
{
|
||||
_saturationGauge = _meter.CreateObservableGauge(
|
||||
name: $"{_options.Prefix}saturation_ratio",
|
||||
observeValue: () => _saturationProvider?.Invoke() ?? 0.0,
|
||||
unit: "1",
|
||||
description: "Resource saturation ratio (0.0-1.0).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a saturation provider function.
|
||||
/// </summary>
|
||||
/// <param name="provider">Function that returns current saturation ratio (0.0-1.0).</param>
|
||||
public void SetSaturationProvider(Func<double> provider)
|
||||
{
|
||||
_saturationProvider = provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a request latency measurement.
|
||||
/// </summary>
|
||||
/// <param name="durationSeconds">Duration in seconds.</param>
|
||||
/// <param name="tags">Optional tags (labels) for the measurement.</param>
|
||||
public void RecordLatency(double durationSeconds, params KeyValuePair<string, object?>[] tags)
|
||||
{
|
||||
if (!ValidateAndLogCardinality(tags)) return;
|
||||
|
||||
var tagList = CreateTagListWithExemplar(tags);
|
||||
_latencyHistogram.Record(durationSeconds, tagList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a request latency measurement using a stopwatch.
|
||||
/// </summary>
|
||||
/// <param name="stopwatch">Stopwatch that was started at the beginning of the operation.</param>
|
||||
/// <param name="tags">Optional tags (labels) for the measurement.</param>
|
||||
public void RecordLatency(Stopwatch stopwatch, params KeyValuePair<string, object?>[] tags)
|
||||
{
|
||||
RecordLatency(stopwatch.Elapsed.TotalSeconds, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a latency measurement scope that records duration on disposal.
|
||||
/// </summary>
|
||||
/// <param name="tags">Optional tags (labels) for the measurement.</param>
|
||||
/// <returns>A disposable scope.</returns>
|
||||
public IDisposable MeasureLatency(params KeyValuePair<string, object?>[] tags)
|
||||
{
|
||||
return new LatencyScope(this, tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments the error counter.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of errors to add.</param>
|
||||
/// <param name="tags">Optional tags (labels) for the measurement.</param>
|
||||
public void IncrementErrors(long count = 1, params KeyValuePair<string, object?>[] tags)
|
||||
{
|
||||
if (!ValidateAndLogCardinality(tags)) return;
|
||||
|
||||
var tagList = CreateTagListWithExemplar(tags);
|
||||
_errorCounter.Add(count, tagList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increments the request counter.
|
||||
/// </summary>
|
||||
/// <param name="count">Number of requests to add.</param>
|
||||
/// <param name="tags">Optional tags (labels) for the measurement.</param>
|
||||
public void IncrementRequests(long count = 1, params KeyValuePair<string, object?>[] tags)
|
||||
{
|
||||
if (!ValidateAndLogCardinality(tags)) return;
|
||||
|
||||
var tagList = CreateTagListWithExemplar(tags);
|
||||
_requestCounter.Add(count, tagList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a tag for use with metrics.
|
||||
/// </summary>
|
||||
/// <param name="key">Tag key.</param>
|
||||
/// <param name="value">Tag value.</param>
|
||||
/// <returns>A key-value pair suitable for metric tags.</returns>
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) =>
|
||||
new(key, value);
|
||||
|
||||
private bool ValidateAndLogCardinality(KeyValuePair<string, object?>[] tags)
|
||||
{
|
||||
if (tags.Length == 0) return true;
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tag.Key)) continue;
|
||||
|
||||
var valueKey = $"{tag.Key}:{tag.Value}";
|
||||
var currentCount = _labelCounts.AddOrUpdate(tag.Key, 1, (_, c) => c + 1);
|
||||
|
||||
if (currentCount > _options.MaxCardinalityPerLabel)
|
||||
{
|
||||
if (_options.DropHighCardinalityMetrics)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Dropping metric due to high cardinality on label {Label}: {Count} unique values exceeds limit {Limit}",
|
||||
tag.Key,
|
||||
currentCount,
|
||||
_options.MaxCardinalityPerLabel);
|
||||
return false;
|
||||
}
|
||||
else if (currentCount == _options.MaxCardinalityPerLabel + 1)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"High cardinality detected on label {Label}: {Count} unique values. Consider reviewing label usage.",
|
||||
tag.Key,
|
||||
currentCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private TagList CreateTagListWithExemplar(KeyValuePair<string, object?>[] tags)
|
||||
{
|
||||
var tagList = new TagList();
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tag.Key))
|
||||
{
|
||||
tagList.Add(SanitizeLabelKey(tag.Key), tag.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.EnableExemplars && Activity.Current is not null)
|
||||
{
|
||||
tagList.Add("trace_id", Activity.Current.TraceId.ToString());
|
||||
}
|
||||
|
||||
return tagList;
|
||||
}
|
||||
|
||||
private static string SanitizeLabelKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key)) return "unknown";
|
||||
|
||||
var sanitized = new char[key.Length];
|
||||
for (int i = 0; i < key.Length; i++)
|
||||
{
|
||||
char c = key[i];
|
||||
sanitized[i] = char.IsLetterOrDigit(c) || c == '_' ? c : '_';
|
||||
}
|
||||
|
||||
return new string(sanitized);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_meter.Dispose();
|
||||
}
|
||||
|
||||
private sealed class LatencyScope : IDisposable
|
||||
{
|
||||
private readonly GoldenSignalMetrics _metrics;
|
||||
private readonly KeyValuePair<string, object?>[] _tags;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
|
||||
public LatencyScope(GoldenSignalMetrics metrics, KeyValuePair<string, object?>[] tags)
|
||||
{
|
||||
_metrics = metrics;
|
||||
_tags = tags;
|
||||
_stopwatch = Stopwatch.StartNew();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stopwatch.Stop();
|
||||
_metrics.RecordLatency(_stopwatch, _tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="GoldenSignalMetrics"/>.
|
||||
/// </summary>
|
||||
public sealed class GoldenSignalMetricsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the metric name prefix.
|
||||
/// </summary>
|
||||
public string Prefix { get; set; } = "stellaops_";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the meter version.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of unique values allowed per label
|
||||
/// before cardinality warnings are emitted.
|
||||
/// </summary>
|
||||
public int MaxCardinalityPerLabel { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to drop metrics that exceed
|
||||
/// the cardinality threshold. When false, warnings are logged but metrics
|
||||
/// are still recorded.
|
||||
/// </summary>
|
||||
public bool DropHighCardinalityMetrics { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to attach trace_id exemplars
|
||||
/// to metrics when an Activity is present.
|
||||
/// </summary>
|
||||
public bool EnableExemplars { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable the saturation gauge.
|
||||
/// </summary>
|
||||
public bool EnableSaturationGauge { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC server interceptor that extracts telemetry context from incoming call metadata
|
||||
/// and establishes it via <see cref="ITelemetryContextAccessor"/>.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextServerInterceptor : Interceptor
|
||||
{
|
||||
private readonly ITelemetryContextAccessor _contextAccessor;
|
||||
private readonly ILogger<TelemetryContextServerInterceptor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryContextServerInterceptor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The telemetry context accessor.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public TelemetryContextServerInterceptor(
|
||||
ITelemetryContextAccessor contextAccessor,
|
||||
ILogger<TelemetryContextServerInterceptor> logger)
|
||||
{
|
||||
_contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
var telemetryContext = ExtractContext(context.RequestHeaders);
|
||||
_contextAccessor.Context = telemetryContext;
|
||||
EnrichActivity(Activity.Current, telemetryContext);
|
||||
|
||||
_logger.LogTrace(
|
||||
"gRPC telemetry context established: TenantId={TenantId}, Actor={Actor}, CorrelationId={CorrelationId}",
|
||||
telemetryContext.TenantId ?? "(none)",
|
||||
telemetryContext.Actor ?? "(none)",
|
||||
telemetryContext.CorrelationId ?? "(none)");
|
||||
|
||||
try
|
||||
{
|
||||
return await continuation(request, context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_contextAccessor.Context = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
ServerCallContext context,
|
||||
ClientStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
var telemetryContext = ExtractContext(context.RequestHeaders);
|
||||
_contextAccessor.Context = telemetryContext;
|
||||
EnrichActivity(Activity.Current, telemetryContext);
|
||||
|
||||
try
|
||||
{
|
||||
return await continuation(requestStream, context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_contextAccessor.Context = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
var telemetryContext = ExtractContext(context.RequestHeaders);
|
||||
_contextAccessor.Context = telemetryContext;
|
||||
EnrichActivity(Activity.Current, telemetryContext);
|
||||
|
||||
try
|
||||
{
|
||||
await continuation(request, responseStream, context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_contextAccessor.Context = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
||||
IAsyncStreamReader<TRequest> requestStream,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
var telemetryContext = ExtractContext(context.RequestHeaders);
|
||||
_contextAccessor.Context = telemetryContext;
|
||||
EnrichActivity(Activity.Current, telemetryContext);
|
||||
|
||||
try
|
||||
{
|
||||
await continuation(requestStream, responseStream, context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_contextAccessor.Context = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static TelemetryContext ExtractContext(Metadata headers)
|
||||
{
|
||||
var context = new TelemetryContext();
|
||||
|
||||
var tenantId = headers.GetValue(TelemetryContextPropagationMiddleware.TenantIdHeader.ToLowerInvariant());
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
context.TenantId = tenantId;
|
||||
}
|
||||
|
||||
var actor = headers.GetValue(TelemetryContextPropagationMiddleware.ActorHeader.ToLowerInvariant());
|
||||
if (!string.IsNullOrEmpty(actor))
|
||||
{
|
||||
context.Actor = actor;
|
||||
}
|
||||
|
||||
var imposedRule = headers.GetValue(TelemetryContextPropagationMiddleware.ImposedRuleHeader.ToLowerInvariant());
|
||||
if (!string.IsNullOrEmpty(imposedRule))
|
||||
{
|
||||
context.ImposedRule = imposedRule;
|
||||
}
|
||||
|
||||
var correlationId = headers.GetValue(TelemetryContextPropagationMiddleware.CorrelationIdHeader.ToLowerInvariant());
|
||||
if (!string.IsNullOrEmpty(correlationId))
|
||||
{
|
||||
context.CorrelationId = correlationId;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.CorrelationId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void EnrichActivity(Activity? activity, TelemetryContext context)
|
||||
{
|
||||
if (activity is null) return;
|
||||
|
||||
if (!string.IsNullOrEmpty(context.TenantId))
|
||||
{
|
||||
activity.SetTag("tenant.id", context.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Actor))
|
||||
{
|
||||
activity.SetTag("actor.id", context.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.ImposedRule))
|
||||
{
|
||||
activity.SetTag("imposed.rule", context.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CorrelationId))
|
||||
{
|
||||
activity.SetTag("correlation.id", context.CorrelationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// gRPC client interceptor that injects telemetry context into outgoing call metadata.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextClientInterceptor : Interceptor
|
||||
{
|
||||
private readonly ITelemetryContextAccessor _contextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryContextClientInterceptor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The telemetry context accessor.</param>
|
||||
public TelemetryContextClientInterceptor(ITelemetryContextAccessor contextAccessor)
|
||||
{
|
||||
_contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
|
||||
{
|
||||
var newContext = InjectContext(context);
|
||||
return continuation(request, newContext);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override TResponse BlockingUnaryCall<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
BlockingUnaryCallContinuation<TRequest, TResponse> continuation)
|
||||
{
|
||||
var newContext = InjectContext(context);
|
||||
return continuation(request, newContext);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AsyncClientStreamingCall<TRequest, TResponse> AsyncClientStreamingCall<TRequest, TResponse>(
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
AsyncClientStreamingCallContinuation<TRequest, TResponse> continuation)
|
||||
{
|
||||
var newContext = InjectContext(context);
|
||||
return continuation(newContext);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
AsyncServerStreamingCallContinuation<TRequest, TResponse> continuation)
|
||||
{
|
||||
var newContext = InjectContext(context);
|
||||
return continuation(request, newContext);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AsyncDuplexStreamingCall<TRequest, TResponse> AsyncDuplexStreamingCall<TRequest, TResponse>(
|
||||
ClientInterceptorContext<TRequest, TResponse> context,
|
||||
AsyncDuplexStreamingCallContinuation<TRequest, TResponse> continuation)
|
||||
{
|
||||
var newContext = InjectContext(context);
|
||||
return continuation(newContext);
|
||||
}
|
||||
|
||||
private ClientInterceptorContext<TRequest, TResponse> InjectContext<TRequest, TResponse>(
|
||||
ClientInterceptorContext<TRequest, TResponse> context)
|
||||
where TRequest : class
|
||||
where TResponse : class
|
||||
{
|
||||
var telemetryContext = _contextAccessor.Context;
|
||||
if (telemetryContext is null)
|
||||
{
|
||||
return context;
|
||||
}
|
||||
|
||||
var headers = context.Options.Headers ?? new Metadata();
|
||||
|
||||
if (!string.IsNullOrEmpty(telemetryContext.TenantId))
|
||||
{
|
||||
headers.Add(TelemetryContextPropagationMiddleware.TenantIdHeader.ToLowerInvariant(), telemetryContext.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(telemetryContext.Actor))
|
||||
{
|
||||
headers.Add(TelemetryContextPropagationMiddleware.ActorHeader.ToLowerInvariant(), telemetryContext.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(telemetryContext.ImposedRule))
|
||||
{
|
||||
headers.Add(TelemetryContextPropagationMiddleware.ImposedRuleHeader.ToLowerInvariant(), telemetryContext.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(telemetryContext.CorrelationId))
|
||||
{
|
||||
headers.Add(TelemetryContextPropagationMiddleware.CorrelationIdHeader.ToLowerInvariant(), telemetryContext.CorrelationId);
|
||||
}
|
||||
|
||||
var newOptions = context.Options.WithHeaders(headers);
|
||||
return new ClientInterceptorContext<TRequest, TResponse>(context.Method, context.Host, newOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for gRPC metadata.
|
||||
/// </summary>
|
||||
internal static class MetadataExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a metadata value by key, or null if not found.
|
||||
/// </summary>
|
||||
public static string? GetValue(this Metadata metadata, string key)
|
||||
{
|
||||
foreach (var entry in metadata)
|
||||
{
|
||||
if (string.Equals(entry.Key, key, StringComparison.OrdinalIgnoreCase) && !entry.IsBinary)
|
||||
{
|
||||
return entry.Value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Service for redacting sensitive information from log data.
|
||||
/// </summary>
|
||||
public interface ILogRedactor
|
||||
{
|
||||
/// <summary>
|
||||
/// Redacts sensitive information from the provided text.
|
||||
/// </summary>
|
||||
/// <param name="value">The text to redact.</param>
|
||||
/// <param name="tenantId">Optional tenant identifier for tenant-specific rules.</param>
|
||||
/// <returns>The redacted text.</returns>
|
||||
string RedactString(string? value, string? tenantId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a field name should have its value redacted.
|
||||
/// </summary>
|
||||
/// <param name="fieldName">The field name to check.</param>
|
||||
/// <param name="tenantId">Optional tenant identifier for tenant-specific rules.</param>
|
||||
/// <returns><c>true</c> if the field should be redacted.</returns>
|
||||
bool IsSensitiveField(string fieldName, string? tenantId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Redacts a dictionary of log attributes in place.
|
||||
/// </summary>
|
||||
/// <param name="attributes">The attributes dictionary to redact.</param>
|
||||
/// <param name="tenantId">Optional tenant identifier for tenant-specific rules.</param>
|
||||
/// <returns>Redaction result containing audit information.</returns>
|
||||
RedactionResult RedactAttributes(IDictionary<string, object?> attributes, string? tenantId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether redaction is currently enabled.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Optional tenant identifier.</param>
|
||||
/// <returns><c>true</c> if redaction is enabled.</returns>
|
||||
bool IsRedactionEnabled(string? tenantId = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a redaction operation for audit purposes.
|
||||
/// </summary>
|
||||
public sealed class RedactionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the number of fields that were redacted.
|
||||
/// </summary>
|
||||
public int RedactedFieldCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the names of fields that were redacted.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> RedactedFieldNames { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the names of patterns that matched during redaction.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> MatchedPatterns { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any override was applied for this redaction.
|
||||
/// </summary>
|
||||
public bool OverrideApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID if tenant-specific rules were applied.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An empty result indicating no redaction was performed.
|
||||
/// </summary>
|
||||
public static RedactionResult None { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current <see cref="TelemetryContext"/>.
|
||||
/// </summary>
|
||||
public interface ITelemetryContextAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current telemetry context.
|
||||
/// </summary>
|
||||
TelemetryContext? Context { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Options for log redaction and scrubbing.
|
||||
/// </summary>
|
||||
public sealed class LogRedactionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether redaction is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the placeholder used to replace redacted values.
|
||||
/// </summary>
|
||||
public string RedactionPlaceholder { get; set; } = "[REDACTED]";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets sensitive field names that should always be redacted.
|
||||
/// Case-insensitive matching is applied.
|
||||
/// </summary>
|
||||
public HashSet<string> SensitiveFieldNames { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"password", "passwd", "pwd", "secret", "apikey", "api_key",
|
||||
"token", "accesstoken", "access_token", "refreshtoken", "refresh_token",
|
||||
"bearertoken", "bearer_token", "authtoken", "auth_token",
|
||||
"credential", "credentials", "privatekey", "private_key",
|
||||
"connectionstring", "connection_string", "connstring", "conn_string",
|
||||
"ssn", "social_security", "creditcard", "credit_card", "cvv", "ccv",
|
||||
"authorization", "x-api-key", "x-auth-token"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets regex patterns for detecting sensitive values.
|
||||
/// </summary>
|
||||
public List<SensitiveDataPattern> ValuePatterns { get; set; } = new()
|
||||
{
|
||||
// JWT tokens
|
||||
new SensitiveDataPattern("JWT", @"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"),
|
||||
// Bearer tokens
|
||||
new SensitiveDataPattern("Bearer", @"Bearer\s+[A-Za-z0-9_-]+\.?[A-Za-z0-9_-]*\.?[A-Za-z0-9_-]*"),
|
||||
// Email addresses
|
||||
new SensitiveDataPattern("Email", @"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"),
|
||||
// Credit card numbers (basic patterns)
|
||||
new SensitiveDataPattern("CreditCard", @"\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b"),
|
||||
// Social Security Numbers
|
||||
new SensitiveDataPattern("SSN", @"\b\d{3}-\d{2}-\d{4}\b"),
|
||||
// API keys (common formats)
|
||||
new SensitiveDataPattern("APIKey", @"\b[a-zA-Z0-9_-]{32,}\b"),
|
||||
// IP addresses (for PII compliance)
|
||||
new SensitiveDataPattern("IPAddress", @"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b"),
|
||||
// Private keys
|
||||
new SensitiveDataPattern("PrivateKey", @"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----"),
|
||||
// AWS access keys
|
||||
new SensitiveDataPattern("AWSKey", @"\b(AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16}\b"),
|
||||
// Connection strings
|
||||
new SensitiveDataPattern("ConnectionString", @"(?:password|pwd)\s*=\s*[^;]+", RegexOptions.IgnoreCase),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets per-tenant override configurations.
|
||||
/// </summary>
|
||||
public Dictionary<string, TenantRedactionOverride> TenantOverrides { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to audit redaction overrides.
|
||||
/// </summary>
|
||||
public bool AuditOverrides { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TTL in seconds for cached tenant configurations.
|
||||
/// </summary>
|
||||
public int TenantCacheTtlSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets fields to exclude from redaction (whitelist).
|
||||
/// </summary>
|
||||
public HashSet<string> ExcludedFields { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"TraceId", "SpanId", "ParentId", "RequestId", "CorrelationId"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a pattern for detecting sensitive data.
|
||||
/// </summary>
|
||||
public sealed class SensitiveDataPattern
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the pattern name for audit purposes.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the regex pattern string.
|
||||
/// </summary>
|
||||
public string Pattern { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the regex options.
|
||||
/// </summary>
|
||||
public RegexOptions Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compiled regex for matching.
|
||||
/// </summary>
|
||||
public Regex CompiledRegex { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="SensitiveDataPattern"/>.
|
||||
/// </summary>
|
||||
/// <param name="name">Pattern name.</param>
|
||||
/// <param name="pattern">Regex pattern.</param>
|
||||
/// <param name="options">Optional regex options.</param>
|
||||
public SensitiveDataPattern(string name, string pattern, RegexOptions options = RegexOptions.None)
|
||||
{
|
||||
Name = name;
|
||||
Pattern = pattern;
|
||||
Options = options | RegexOptions.Compiled;
|
||||
CompiledRegex = new Regex(pattern, Options);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant redaction override configuration.
|
||||
/// </summary>
|
||||
public sealed class TenantRedactionOverride
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets additional sensitive field names for this tenant.
|
||||
/// </summary>
|
||||
public HashSet<string> AdditionalSensitiveFields { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets fields to exclude from redaction for this tenant.
|
||||
/// </summary>
|
||||
public HashSet<string> ExcludedFields { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional value patterns for this tenant.
|
||||
/// </summary>
|
||||
public List<SensitiveDataPattern> AdditionalPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to completely disable redaction for this tenant.
|
||||
/// Requires elevated permissions and will be audited.
|
||||
/// </summary>
|
||||
public bool DisableRedaction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the reason for any override (required for audit).
|
||||
/// </summary>
|
||||
public string? OverrideReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when this override expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ILogRedactor"/> that redacts sensitive data from logs.
|
||||
/// </summary>
|
||||
public sealed class LogRedactor : ILogRedactor
|
||||
{
|
||||
private readonly IOptionsMonitor<LogRedactionOptions> _optionsMonitor;
|
||||
private readonly ILogger<LogRedactor>? _logger;
|
||||
private readonly ConcurrentDictionary<string, (TenantRedactionOverride Override, DateTimeOffset CachedAt)> _tenantCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="LogRedactor"/>.
|
||||
/// </summary>
|
||||
/// <param name="optionsMonitor">Options monitor for redaction configuration.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
public LogRedactor(IOptionsMonitor<LogRedactionOptions> optionsMonitor, ILogger<LogRedactor>? logger = null)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsRedactionEnabled(string? tenantId = null)
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
var tenantOverride = GetTenantOverride(tenantId, options);
|
||||
if (tenantOverride is not null && tenantOverride.DisableRedaction)
|
||||
{
|
||||
if (!IsOverrideExpired(tenantOverride))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsSensitiveField(string fieldName, string? tenantId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fieldName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
|
||||
// Check exclusions first
|
||||
if (options.ExcludedFields.Contains(fieldName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check tenant-specific exclusions
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
var tenantOverride = GetTenantOverride(tenantId, options);
|
||||
if (tenantOverride is not null && !IsOverrideExpired(tenantOverride))
|
||||
{
|
||||
if (tenantOverride.ExcludedFields.Contains(fieldName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check tenant-specific sensitive fields
|
||||
if (tenantOverride.AdditionalSensitiveFields.Contains(fieldName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check global sensitive fields
|
||||
return options.SensitiveFieldNames.Contains(fieldName);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string RedactString(string? value, string? tenantId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
var tenantOverride = GetTenantOverride(tenantId, options);
|
||||
if (tenantOverride is not null && tenantOverride.DisableRedaction && !IsOverrideExpired(tenantOverride))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
var result = value;
|
||||
|
||||
// Apply global patterns
|
||||
foreach (var pattern in options.ValuePatterns)
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
}
|
||||
|
||||
// Apply tenant-specific patterns
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
var tenantOverride = GetTenantOverride(tenantId, options);
|
||||
if (tenantOverride is not null && !IsOverrideExpired(tenantOverride))
|
||||
{
|
||||
foreach (var pattern in tenantOverride.AdditionalPatterns)
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public RedactionResult RedactAttributes(IDictionary<string, object?> attributes, string? tenantId = null)
|
||||
{
|
||||
if (attributes == null || attributes.Count == 0)
|
||||
{
|
||||
return RedactionResult.None;
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return RedactionResult.None;
|
||||
}
|
||||
|
||||
var overrideApplied = false;
|
||||
TenantRedactionOverride? tenantOverride = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
tenantOverride = GetTenantOverride(tenantId, options);
|
||||
if (tenantOverride is not null && !IsOverrideExpired(tenantOverride))
|
||||
{
|
||||
overrideApplied = true;
|
||||
if (tenantOverride.DisableRedaction)
|
||||
{
|
||||
AuditOverrideUsage(tenantId, tenantOverride, "Redaction disabled");
|
||||
return new RedactionResult
|
||||
{
|
||||
OverrideApplied = true,
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var redactedFields = new List<string>();
|
||||
var matchedPatterns = new HashSet<string>();
|
||||
|
||||
// Get all keys to iterate (avoid modifying collection during enumeration)
|
||||
var keys = attributes.Keys.ToList();
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
// Check if field should be excluded
|
||||
if (options.ExcludedFields.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tenantOverride is not null && tenantOverride.ExcludedFields.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a sensitive field name
|
||||
if (IsSensitiveFieldInternal(key, options, tenantOverride))
|
||||
{
|
||||
attributes[key] = options.RedactionPlaceholder;
|
||||
redactedFields.Add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check and redact string values
|
||||
if (attributes[key] is string stringValue && !string.IsNullOrEmpty(stringValue))
|
||||
{
|
||||
var (redactedValue, patterns) = RedactStringWithPatternTracking(stringValue, options, tenantOverride);
|
||||
if (redactedValue != stringValue)
|
||||
{
|
||||
attributes[key] = redactedValue;
|
||||
redactedFields.Add(key);
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
matchedPatterns.Add(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (overrideApplied && options.AuditOverrides && redactedFields.Count > 0)
|
||||
{
|
||||
AuditOverrideUsage(tenantId!, tenantOverride!, $"Redacted {redactedFields.Count} fields with custom rules");
|
||||
}
|
||||
|
||||
return new RedactionResult
|
||||
{
|
||||
RedactedFieldCount = redactedFields.Count,
|
||||
RedactedFieldNames = redactedFields,
|
||||
MatchedPatterns = matchedPatterns.ToList(),
|
||||
OverrideApplied = overrideApplied,
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsSensitiveFieldInternal(string fieldName, LogRedactionOptions options, TenantRedactionOverride? tenantOverride)
|
||||
{
|
||||
if (options.SensitiveFieldNames.Contains(fieldName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tenantOverride is not null && tenantOverride.AdditionalSensitiveFields.Contains(fieldName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
|
||||
string value,
|
||||
LogRedactionOptions options,
|
||||
TenantRedactionOverride? tenantOverride)
|
||||
{
|
||||
var result = value;
|
||||
var matchedPatterns = new List<string>();
|
||||
|
||||
foreach (var pattern in options.ValuePatterns)
|
||||
{
|
||||
if (pattern.CompiledRegex.IsMatch(result))
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantOverride is not null)
|
||||
{
|
||||
foreach (var pattern in tenantOverride.AdditionalPatterns)
|
||||
{
|
||||
if (pattern.CompiledRegex.IsMatch(result))
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (result, matchedPatterns);
|
||||
}
|
||||
|
||||
private TenantRedactionOverride? GetTenantOverride(string tenantId, LogRedactionOptions options)
|
||||
{
|
||||
if (!options.TenantOverrides.TryGetValue(tenantId, out var configuredOverride))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (_tenantCache.TryGetValue(tenantId, out var cached))
|
||||
{
|
||||
var cacheAge = DateTimeOffset.UtcNow - cached.CachedAt;
|
||||
if (cacheAge.TotalSeconds < options.TenantCacheTtlSeconds)
|
||||
{
|
||||
return cached.Override;
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
_tenantCache[tenantId] = (configuredOverride, DateTimeOffset.UtcNow);
|
||||
return configuredOverride;
|
||||
}
|
||||
|
||||
private static bool IsOverrideExpired(TenantRedactionOverride tenantOverride)
|
||||
{
|
||||
return tenantOverride.ExpiresAt.HasValue && tenantOverride.ExpiresAt.Value < DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private void AuditOverrideUsage(string tenantId, TenantRedactionOverride tenantOverride, string action)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Redaction override applied for tenant {TenantId}: {Action}. Reason: {Reason}. Expires: {ExpiresAt}",
|
||||
tenantId,
|
||||
action,
|
||||
tenantOverride.OverrideReason ?? "Not specified",
|
||||
tenantOverride.ExpiresAt?.ToString("O") ?? "Never");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Logs;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// OpenTelemetry log processor that redacts sensitive information from log records.
|
||||
/// </summary>
|
||||
public sealed class RedactingLogProcessor : BaseProcessor<LogRecord>
|
||||
{
|
||||
private readonly ILogRedactor _redactor;
|
||||
private readonly ITelemetryContextAccessor? _contextAccessor;
|
||||
private readonly ILogger<RedactingLogProcessor>? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="RedactingLogProcessor"/>.
|
||||
/// </summary>
|
||||
/// <param name="redactor">The redactor service.</param>
|
||||
/// <param name="contextAccessor">Optional telemetry context accessor for tenant resolution.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
public RedactingLogProcessor(
|
||||
ILogRedactor redactor,
|
||||
ITelemetryContextAccessor? contextAccessor = null,
|
||||
ILogger<RedactingLogProcessor>? logger = null)
|
||||
{
|
||||
_redactor = redactor ?? throw new ArgumentNullException(nameof(redactor));
|
||||
_contextAccessor = contextAccessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void OnEnd(LogRecord data)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = _contextAccessor?.Context?.TenantId;
|
||||
|
||||
if (!_redactor.IsRedactionEnabled(tenantId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Redact state (structured log properties)
|
||||
if (data.State is IReadOnlyList<KeyValuePair<string, object?>> stateList)
|
||||
{
|
||||
RedactStateList(stateList, tenantId);
|
||||
}
|
||||
|
||||
// Redact attributes if available
|
||||
if (data.Attributes is not null)
|
||||
{
|
||||
var attributeDict = new Dictionary<string, object?>();
|
||||
foreach (var attr in data.Attributes)
|
||||
{
|
||||
attributeDict[attr.Key] = attr.Value;
|
||||
}
|
||||
|
||||
var result = _redactor.RedactAttributes(attributeDict, tenantId);
|
||||
if (result.RedactedFieldCount > 0)
|
||||
{
|
||||
_logger?.LogDebug(
|
||||
"Redacted {Count} attributes from log record. Patterns: {Patterns}",
|
||||
result.RedactedFieldCount,
|
||||
string.Join(", ", result.MatchedPatterns));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't let redaction failures break logging
|
||||
_logger?.LogWarning(ex, "Failed to redact log record");
|
||||
}
|
||||
}
|
||||
|
||||
private void RedactStateList(IReadOnlyList<KeyValuePair<string, object?>> stateList, string? tenantId)
|
||||
{
|
||||
// IReadOnlyList doesn't support modification, but we can still redact the values
|
||||
// if the underlying objects are mutable. For full redaction support,
|
||||
// applications should use the RedactingLoggerProvider or structured logging
|
||||
// patterns that flow through the processor before serialization.
|
||||
foreach (var kvp in stateList)
|
||||
{
|
||||
if (_redactor.IsSensitiveField(kvp.Key, tenantId))
|
||||
{
|
||||
// Note: We can't modify IReadOnlyList entries directly.
|
||||
// This is logged for diagnostic purposes. The real redaction happens
|
||||
// at the exporter level or through the RedactingLoggerProvider.
|
||||
_logger?.LogTrace("Detected sensitive field in log state: {FieldName}", kvp.Key);
|
||||
}
|
||||
else if (kvp.Value is string stringValue)
|
||||
{
|
||||
var redacted = _redactor.RedactString(stringValue, tenantId);
|
||||
if (redacted != stringValue)
|
||||
{
|
||||
_logger?.LogTrace("Detected sensitive pattern in field: {FieldName}", kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for configuring redacting log processor.
|
||||
/// </summary>
|
||||
public static class RedactingLogProcessorExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the redacting log processor to the logger options.
|
||||
/// </summary>
|
||||
/// <param name="options">The logger options.</param>
|
||||
/// <param name="redactor">The redactor service.</param>
|
||||
/// <param name="contextAccessor">Optional telemetry context accessor.</param>
|
||||
/// <param name="logger">Optional diagnostic logger.</param>
|
||||
/// <returns>The options for chaining.</returns>
|
||||
public static OpenTelemetryLoggerOptions AddRedactingProcessor(
|
||||
this OpenTelemetryLoggerOptions options,
|
||||
ILogRedactor redactor,
|
||||
ITelemetryContextAccessor? contextAccessor = null,
|
||||
ILogger<RedactingLogProcessor>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(redactor);
|
||||
|
||||
options.AddProcessor(new RedactingLogProcessor(redactor, contextAccessor, logger));
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\..\\AirGap\\StellaOps.AirGap.Policy\\StellaOps.AirGap.Policy\\StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Application builder extensions for telemetry middleware.
|
||||
/// </summary>
|
||||
public static class TelemetryApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the telemetry context propagation middleware to the pipeline.
|
||||
/// Should be added early in the pipeline, after routing but before authorization.
|
||||
/// </summary>
|
||||
/// <param name="app">The application builder.</param>
|
||||
/// <returns>The application builder for chaining.</returns>
|
||||
public static IApplicationBuilder UseTelemetryContextPropagation(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<TelemetryContextPropagationMiddleware>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the contextual metadata propagated through distributed requests.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the trace identifier from the current activity or an empty string if unavailable.
|
||||
/// </summary>
|
||||
public string TraceId => Activity.Current?.TraceId.ToString() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the span identifier from the current activity or an empty string if unavailable.
|
||||
/// </summary>
|
||||
public string SpanId => Activity.Current?.SpanId.ToString() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the actor (user/service principal) identifier.
|
||||
/// </summary>
|
||||
public string? Actor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the imposed rule identifier when operating under policy enforcement.
|
||||
/// </summary>
|
||||
public string? ImposedRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the correlation identifier for linking related operations.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this context has been initialized with values.
|
||||
/// </summary>
|
||||
public bool IsInitialized =>
|
||||
!string.IsNullOrEmpty(TenantId) ||
|
||||
!string.IsNullOrEmpty(Actor) ||
|
||||
!string.IsNullOrEmpty(CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of this context for async continuation.
|
||||
/// </summary>
|
||||
/// <returns>A shallow copy of this context.</returns>
|
||||
public TelemetryContext Clone() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Actor = Actor,
|
||||
ImposedRule = ImposedRule,
|
||||
CorrelationId = CorrelationId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Context
|
||||
{
|
||||
get => CurrentHolder.Value?.Context;
|
||||
set
|
||||
{
|
||||
var holder = CurrentHolder.Value;
|
||||
if (holder is not null)
|
||||
{
|
||||
holder.Context = null;
|
||||
}
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
CurrentHolder.Value = new TelemetryContextHolder { Context = value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that restores the context when disposed.
|
||||
/// Useful for background jobs and async continuations.
|
||||
/// </summary>
|
||||
/// <param name="context">The context to set for the scope.</param>
|
||||
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
|
||||
public IDisposable CreateScope(TelemetryContext context)
|
||||
{
|
||||
var previous = Context;
|
||||
Context = context;
|
||||
return new ContextScope(this, previous);
|
||||
}
|
||||
|
||||
private sealed class TelemetryContextHolder
|
||||
{
|
||||
public TelemetryContext? Context { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ContextScope : IDisposable
|
||||
{
|
||||
private readonly TelemetryContextAccessor _accessor;
|
||||
private readonly TelemetryContext? _previous;
|
||||
private bool _disposed;
|
||||
|
||||
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
|
||||
{
|
||||
_accessor = accessor;
|
||||
_previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_accessor.Context = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides utilities for capturing and resuming telemetry context across async job boundaries.
|
||||
/// Use this when enqueueing background work to preserve context correlation.
|
||||
/// </summary>
|
||||
public static class TelemetryContextJobScope
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current telemetry context into a serializable payload.
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The context accessor to read from.</param>
|
||||
/// <returns>A serialized context payload, or null if no context exists.</returns>
|
||||
public static string? CaptureForJob(ITelemetryContextAccessor contextAccessor)
|
||||
{
|
||||
var context = contextAccessor?.Context;
|
||||
if (context is null) return null;
|
||||
|
||||
var payload = new JobContextPayload
|
||||
{
|
||||
TraceId = Activity.Current?.TraceId.ToString(),
|
||||
SpanId = Activity.Current?.SpanId.ToString(),
|
||||
TenantId = context.TenantId,
|
||||
Actor = context.Actor,
|
||||
ImposedRule = context.ImposedRule,
|
||||
CorrelationId = context.CorrelationId,
|
||||
CapturedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes telemetry context from a captured job payload.
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The context accessor to write to.</param>
|
||||
/// <param name="serializedPayload">The serialized context payload.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
/// <returns>A disposable scope that clears the context on disposal.</returns>
|
||||
public static IDisposable ResumeFromJob(
|
||||
TelemetryContextAccessor contextAccessor,
|
||||
string? serializedPayload,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(serializedPayload))
|
||||
{
|
||||
logger?.LogDebug("No telemetry context payload to resume from.");
|
||||
return new NoOpScope();
|
||||
}
|
||||
|
||||
JobContextPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<JobContextPayload>(serializedPayload, SerializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Failed to deserialize telemetry context payload.");
|
||||
return new NoOpScope();
|
||||
}
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
return new NoOpScope();
|
||||
}
|
||||
|
||||
var context = new TelemetryContext
|
||||
{
|
||||
TenantId = payload.TenantId,
|
||||
Actor = payload.Actor,
|
||||
ImposedRule = payload.ImposedRule,
|
||||
CorrelationId = payload.CorrelationId,
|
||||
};
|
||||
|
||||
logger?.LogDebug(
|
||||
"Resuming telemetry context from job: CorrelationId={CorrelationId}, TenantId={TenantId}, CapturedAt={CapturedAt}",
|
||||
context.CorrelationId ?? "(none)",
|
||||
context.TenantId ?? "(none)",
|
||||
payload.CapturedAtUtc?.ToString("O") ?? "(unknown)");
|
||||
|
||||
return contextAccessor.CreateScope(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates headers dictionary from the current context for message queue propagation.
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The context accessor to read from.</param>
|
||||
/// <returns>A dictionary of header key-value pairs.</returns>
|
||||
public static Dictionary<string, string> CreateQueueHeaders(ITelemetryContextAccessor contextAccessor)
|
||||
{
|
||||
var headers = new Dictionary<string, string>();
|
||||
var context = contextAccessor?.Context;
|
||||
|
||||
if (context is not null)
|
||||
{
|
||||
TelemetryContextInjector.Inject(context, headers);
|
||||
}
|
||||
|
||||
if (Activity.Current is not null)
|
||||
{
|
||||
headers["X-Trace-Id"] = Activity.Current.TraceId.ToString();
|
||||
headers["X-Span-Id"] = Activity.Current.SpanId.ToString();
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private sealed class JobContextPayload
|
||||
{
|
||||
public string? TraceId { get; set; }
|
||||
public string? SpanId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public string? ImposedRule { get; set; }
|
||||
public string? CorrelationId { get; set; }
|
||||
public DateTime? CapturedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
private sealed class NoOpScope : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// ASP.NET Core middleware that extracts telemetry context from incoming HTTP requests
|
||||
/// and propagates it via <see cref="ITelemetryContextAccessor"/>.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextPropagationMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Header name for tenant ID propagation.
|
||||
/// </summary>
|
||||
public const string TenantIdHeader = "X-Tenant-Id";
|
||||
|
||||
/// <summary>
|
||||
/// Header name for actor propagation.
|
||||
/// </summary>
|
||||
public const string ActorHeader = "X-Actor";
|
||||
|
||||
/// <summary>
|
||||
/// Header name for imposed rule propagation.
|
||||
/// </summary>
|
||||
public const string ImposedRuleHeader = "X-Imposed-Rule";
|
||||
|
||||
/// <summary>
|
||||
/// Header name for correlation ID propagation.
|
||||
/// </summary>
|
||||
public const string CorrelationIdHeader = "X-Correlation-Id";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ITelemetryContextAccessor _contextAccessor;
|
||||
private readonly ILogger<TelemetryContextPropagationMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryContextPropagationMiddleware"/> class.
|
||||
/// </summary>
|
||||
/// <param name="next">The next middleware in the pipeline.</param>
|
||||
/// <param name="contextAccessor">The telemetry context accessor.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public TelemetryContextPropagationMiddleware(
|
||||
RequestDelegate next,
|
||||
ITelemetryContextAccessor contextAccessor,
|
||||
ILogger<TelemetryContextPropagationMiddleware> logger)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes the middleware.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The HTTP context.</param>
|
||||
public async Task InvokeAsync(HttpContext httpContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
|
||||
var context = ExtractContext(httpContext.Request);
|
||||
_contextAccessor.Context = context;
|
||||
|
||||
EnrichActivity(Activity.Current, context);
|
||||
|
||||
_logger.LogTrace(
|
||||
"Telemetry context established: TenantId={TenantId}, Actor={Actor}, CorrelationId={CorrelationId}",
|
||||
context.TenantId ?? "(none)",
|
||||
context.Actor ?? "(none)",
|
||||
context.CorrelationId ?? "(none)");
|
||||
|
||||
try
|
||||
{
|
||||
await _next(httpContext);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_contextAccessor.Context = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static TelemetryContext ExtractContext(HttpRequest request)
|
||||
{
|
||||
var context = new TelemetryContext();
|
||||
|
||||
if (request.Headers.TryGetValue(TenantIdHeader, out var tenantId))
|
||||
{
|
||||
context.TenantId = tenantId.ToString();
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue(ActorHeader, out var actor))
|
||||
{
|
||||
context.Actor = actor.ToString();
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue(ImposedRuleHeader, out var imposedRule))
|
||||
{
|
||||
context.ImposedRule = imposedRule.ToString();
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue(CorrelationIdHeader, out var correlationId))
|
||||
{
|
||||
context.CorrelationId = correlationId.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
context.CorrelationId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void EnrichActivity(Activity? activity, TelemetryContext context)
|
||||
{
|
||||
if (activity is null) return;
|
||||
|
||||
if (!string.IsNullOrEmpty(context.TenantId))
|
||||
{
|
||||
activity.SetTag("tenant.id", context.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Actor))
|
||||
{
|
||||
activity.SetTag("actor.id", context.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.ImposedRule))
|
||||
{
|
||||
activity.SetTag("imposed.rule", context.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CorrelationId))
|
||||
{
|
||||
activity.SetTag("correlation.id", context.CorrelationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP message handler that propagates telemetry context headers on outgoing requests.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextPropagator : DelegatingHandler
|
||||
{
|
||||
private readonly ITelemetryContextAccessor _contextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryContextPropagator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contextAccessor">The telemetry context accessor.</param>
|
||||
public TelemetryContextPropagator(ITelemetryContextAccessor contextAccessor)
|
||||
{
|
||||
_contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = _contextAccessor.Context;
|
||||
if (context is not null)
|
||||
{
|
||||
InjectHeaders(request, context);
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static void InjectHeaders(HttpRequestMessage request, TelemetryContext context)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(context.TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(TelemetryContextPropagationMiddleware.TenantIdHeader, context.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Actor))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(TelemetryContextPropagationMiddleware.ActorHeader, context.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.ImposedRule))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(TelemetryContextPropagationMiddleware.ImposedRuleHeader, context.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CorrelationId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(TelemetryContextPropagationMiddleware.CorrelationIdHeader, context.CorrelationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static helper for injecting context into header dictionaries.
|
||||
/// Useful for gRPC metadata and message queue headers.
|
||||
/// </summary>
|
||||
public static class TelemetryContextInjector
|
||||
{
|
||||
/// <summary>
|
||||
/// Injects telemetry context values into the provided header dictionary.
|
||||
/// </summary>
|
||||
/// <param name="context">The telemetry context to inject.</param>
|
||||
/// <param name="headers">The target header dictionary.</param>
|
||||
public static void Inject(TelemetryContext? context, IDictionary<string, string> headers)
|
||||
{
|
||||
if (context is null || headers is null) return;
|
||||
|
||||
if (!string.IsNullOrEmpty(context.TenantId))
|
||||
{
|
||||
headers[TelemetryContextPropagationMiddleware.TenantIdHeader] = context.TenantId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.Actor))
|
||||
{
|
||||
headers[TelemetryContextPropagationMiddleware.ActorHeader] = context.Actor;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.ImposedRule))
|
||||
{
|
||||
headers[TelemetryContextPropagationMiddleware.ImposedRuleHeader] = context.ImposedRule;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CorrelationId))
|
||||
{
|
||||
headers[TelemetryContextPropagationMiddleware.CorrelationIdHeader] = context.CorrelationId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts telemetry context values from the provided header dictionary.
|
||||
/// </summary>
|
||||
/// <param name="headers">The source header dictionary.</param>
|
||||
/// <returns>A new <see cref="TelemetryContext"/> with extracted values.</returns>
|
||||
public static TelemetryContext Extract(IDictionary<string, string> headers)
|
||||
{
|
||||
var context = new TelemetryContext();
|
||||
|
||||
if (headers is null) return context;
|
||||
|
||||
if (headers.TryGetValue(TelemetryContextPropagationMiddleware.TenantIdHeader, out var tenantId))
|
||||
{
|
||||
context.TenantId = tenantId;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue(TelemetryContextPropagationMiddleware.ActorHeader, out var actor))
|
||||
{
|
||||
context.Actor = actor;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue(TelemetryContextPropagationMiddleware.ImposedRuleHeader, out var imposedRule))
|
||||
{
|
||||
context.ImposedRule = imposedRule;
|
||||
}
|
||||
|
||||
if (headers.TryGetValue(TelemetryContextPropagationMiddleware.CorrelationIdHeader, out var correlationId))
|
||||
{
|
||||
context.CorrelationId = correlationId;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,71 @@ namespace StellaOps.Telemetry.Core;
|
||||
/// </summary>
|
||||
public static class TelemetryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers log redaction services with default options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to mutate.</param>
|
||||
/// <param name="configureOptions">Optional options configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddLogRedaction(
|
||||
this IServiceCollection services,
|
||||
Action<LogRedactionOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<LogRedactionOptions>()
|
||||
.Configure(options => configureOptions?.Invoke(options));
|
||||
|
||||
services.TryAddSingleton<ILogRedactor, LogRedactor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers telemetry context propagation services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to mutate.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddTelemetryContextPropagation(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<TelemetryContextAccessor>();
|
||||
services.TryAddSingleton<ITelemetryContextAccessor>(sp => sp.GetRequiredService<TelemetryContextAccessor>());
|
||||
services.AddTransient<TelemetryContextPropagator>();
|
||||
|
||||
// Register gRPC interceptors
|
||||
services.AddTransient<TelemetryContextServerInterceptor>();
|
||||
services.AddTransient<TelemetryContextClientInterceptor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers golden signal metrics with cardinality guards and exemplar support.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to mutate.</param>
|
||||
/// <param name="configureOptions">Optional options configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGoldenSignalMetrics(
|
||||
this IServiceCollection services,
|
||||
Action<GoldenSignalMetricsOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<GoldenSignalMetricsOptions>()
|
||||
.Configure(options => configureOptions?.Invoke(options));
|
||||
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<GoldenSignalMetricsOptions>>().Value;
|
||||
var logger = sp.GetService<ILoggerFactory>()?.CreateLogger<GoldenSignalMetrics>();
|
||||
return new GoldenSignalMetrics(options, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the StellaOps telemetry stack with sealed-mode enforcement.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user