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

This commit is contained in:
master
2025-11-27 15:05:48 +02:00
parent 4831c7fcb0
commit e950474a77
278 changed files with 81498 additions and 672 deletions

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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() { }
}
}
}

View File

@@ -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;
}
}

View File

@@ -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]);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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
};
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>();
}
}

View File

@@ -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,
};
}

View File

@@ -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;
}
}
}

View File

@@ -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()
{
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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>