Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
|
||||
namespace StellaOps.Telemetry.Analyzers;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes metric label usage to prevent high-cardinality labels and enforce naming conventions.
|
||||
/// </summary>
|
||||
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||
public sealed class MetricLabelAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic ID for high-cardinality label patterns.
|
||||
/// </summary>
|
||||
public const string HighCardinalityDiagnosticId = "TELEM001";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for invalid label key format.
|
||||
/// </summary>
|
||||
public const string InvalidLabelKeyDiagnosticId = "TELEM002";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for dynamic label values.
|
||||
/// </summary>
|
||||
public const string DynamicLabelDiagnosticId = "TELEM003";
|
||||
|
||||
private static readonly LocalizableString HighCardinalityTitle = "Potential high-cardinality metric label detected";
|
||||
private static readonly LocalizableString HighCardinalityMessage = "Label key '{0}' may cause high cardinality. Avoid using IDs, timestamps, or user-specific values as labels.";
|
||||
private static readonly LocalizableString HighCardinalityDescription = "High-cardinality labels can cause memory exhaustion and poor query performance. Use bounded, categorical values instead.";
|
||||
|
||||
private static readonly LocalizableString InvalidKeyTitle = "Invalid metric label key format";
|
||||
private static readonly LocalizableString InvalidKeyMessage = "Label key '{0}' should use snake_case and contain only lowercase letters, digits, and underscores.";
|
||||
private static readonly LocalizableString InvalidKeyDescription = "Metric label keys should follow Prometheus naming conventions: lowercase snake_case with only [a-z0-9_] characters.";
|
||||
|
||||
private static readonly LocalizableString DynamicLabelTitle = "Dynamic metric label value detected";
|
||||
private static readonly LocalizableString DynamicLabelMessage = "Metric label value appears to be dynamically generated. Consider using predefined constants or enums.";
|
||||
private static readonly LocalizableString DynamicLabelDescription = "Dynamic label values can lead to unbounded cardinality. Use constants, enums, or validated bounded sets.";
|
||||
|
||||
private static readonly DiagnosticDescriptor HighCardinalityRule = new(
|
||||
HighCardinalityDiagnosticId,
|
||||
HighCardinalityTitle,
|
||||
HighCardinalityMessage,
|
||||
"Performance",
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: HighCardinalityDescription);
|
||||
|
||||
private static readonly DiagnosticDescriptor InvalidKeyRule = new(
|
||||
InvalidLabelKeyDiagnosticId,
|
||||
InvalidKeyTitle,
|
||||
InvalidKeyMessage,
|
||||
"Naming",
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: InvalidKeyDescription);
|
||||
|
||||
private static readonly DiagnosticDescriptor DynamicLabelRule = new(
|
||||
DynamicLabelDiagnosticId,
|
||||
DynamicLabelTitle,
|
||||
DynamicLabelMessage,
|
||||
"Performance",
|
||||
DiagnosticSeverity.Info,
|
||||
isEnabledByDefault: true,
|
||||
description: DynamicLabelDescription);
|
||||
|
||||
// Patterns that suggest high-cardinality labels
|
||||
private static readonly string[] HighCardinalityPatterns =
|
||||
{
|
||||
"id", "guid", "uuid", "user_id", "request_id", "session_id", "transaction_id",
|
||||
"timestamp", "datetime", "time", "date",
|
||||
"email", "username", "name", "ip", "address",
|
||||
"path", "url", "uri", "query",
|
||||
"message", "error_message", "description", "body", "content"
|
||||
};
|
||||
|
||||
// Valid label key pattern: lowercase snake_case
|
||||
private static readonly Regex ValidLabelKeyPattern = new(@"^[a-z][a-z0-9_]*$", RegexOptions.Compiled);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
|
||||
ImmutableArray.Create(HighCardinalityRule, InvalidKeyRule, DynamicLabelRule);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
if (context == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
|
||||
// Analyze invocations of metric methods
|
||||
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
|
||||
}
|
||||
|
||||
private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
if (context.Node is not InvocationExpressionSyntax invocation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation);
|
||||
if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a metric recording method
|
||||
if (!IsMetricMethod(methodSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Analyze the arguments for label-related patterns
|
||||
foreach (var argument in invocation.ArgumentList.Arguments)
|
||||
{
|
||||
AnalyzeArgument(context, argument);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsMetricMethod(IMethodSymbol method)
|
||||
{
|
||||
var containingType = method.ContainingType?.ToDisplayString();
|
||||
|
||||
// Check for GoldenSignalMetrics methods
|
||||
if (containingType?.Contains("GoldenSignalMetrics") == true)
|
||||
{
|
||||
return method.Name is "RecordLatency" or "IncrementErrors" or "IncrementRequests" or "Tag";
|
||||
}
|
||||
|
||||
// Check for System.Diagnostics.Metrics methods
|
||||
if (containingType?.StartsWith("System.Diagnostics.Metrics.") == true)
|
||||
{
|
||||
return method.Name is "Record" or "Add" or "CreateCounter" or "CreateHistogram" or "CreateGauge";
|
||||
}
|
||||
|
||||
// Check for OpenTelemetry methods
|
||||
if (containingType?.Contains("OpenTelemetry") == true && containingType.Contains("Meter"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AnalyzeArgument(SyntaxNodeAnalysisContext context, ArgumentSyntax argument)
|
||||
{
|
||||
// Check for KeyValuePair creation (Tag method calls)
|
||||
if (argument.Expression is InvocationExpressionSyntax tagInvocation)
|
||||
{
|
||||
var tagSymbol = context.SemanticModel.GetSymbolInfo(tagInvocation).Symbol as IMethodSymbol;
|
||||
if (tagSymbol?.Name == "Tag" && tagInvocation.ArgumentList.Arguments.Count >= 2)
|
||||
{
|
||||
var keyArg = tagInvocation.ArgumentList.Arguments[0];
|
||||
var valueArg = tagInvocation.ArgumentList.Arguments[1];
|
||||
|
||||
AnalyzeLabelKey(context, keyArg.Expression);
|
||||
AnalyzeLabelValue(context, valueArg.Expression);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new KeyValuePair<string, object?>(key, value)
|
||||
if (argument.Expression is ObjectCreationExpressionSyntax objectCreation)
|
||||
{
|
||||
var typeSymbol = context.SemanticModel.GetSymbolInfo(objectCreation.Type).Symbol as INamedTypeSymbol;
|
||||
if (typeSymbol?.Name == "KeyValuePair" && objectCreation.ArgumentList?.Arguments.Count >= 2)
|
||||
{
|
||||
var keyArg = objectCreation.ArgumentList.Arguments[0];
|
||||
var valueArg = objectCreation.ArgumentList.Arguments[1];
|
||||
|
||||
AnalyzeLabelKey(context, keyArg.Expression);
|
||||
AnalyzeLabelValue(context, valueArg.Expression);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for tuple-like implicit conversions
|
||||
if (argument.Expression is TupleExpressionSyntax tuple && tuple.Arguments.Count >= 2)
|
||||
{
|
||||
AnalyzeLabelKey(context, tuple.Arguments[0].Expression);
|
||||
AnalyzeLabelValue(context, tuple.Arguments[1].Expression);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeLabelKey(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
|
||||
{
|
||||
// Get the constant value if it's a literal or const
|
||||
var constantValue = context.SemanticModel.GetConstantValue(expression);
|
||||
if (!constantValue.HasValue || constantValue.Value is not string keyString)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for valid label key format
|
||||
if (!ValidLabelKeyPattern.IsMatch(keyString))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(InvalidKeyRule, expression.GetLocation(), keyString);
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
|
||||
// Check for high-cardinality patterns
|
||||
var keyLower = keyString.ToLowerInvariant();
|
||||
foreach (var pattern in HighCardinalityPatterns)
|
||||
{
|
||||
if (keyLower.Contains(pattern))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(HighCardinalityRule, expression.GetLocation(), keyString);
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeLabelValue(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
|
||||
{
|
||||
// If the value is a literal string or const, it's fine
|
||||
var constantValue = context.SemanticModel.GetConstantValue(expression);
|
||||
if (constantValue.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's an enum member access - that's fine
|
||||
var typeInfo = context.SemanticModel.GetTypeInfo(expression);
|
||||
if (typeInfo.Type?.TypeKind == TypeKind.Enum)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's accessing a static/const field - that's fine
|
||||
if (expression is MemberAccessExpressionSyntax memberAccess)
|
||||
{
|
||||
var symbol = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol;
|
||||
if (symbol is IFieldSymbol { IsConst: true } or IFieldSymbol { IsStatic: true, IsReadOnly: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for .ToString() calls on enums - that's fine
|
||||
if (expression is InvocationExpressionSyntax toStringInvocation)
|
||||
{
|
||||
if (toStringInvocation.Expression is MemberAccessExpressionSyntax toStringAccess &&
|
||||
toStringAccess.Name.Identifier.Text == "ToString")
|
||||
{
|
||||
var targetTypeInfo = context.SemanticModel.GetTypeInfo(toStringAccess.Expression);
|
||||
if (targetTypeInfo.Type?.TypeKind == TypeKind.Enum)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag potentially dynamic values
|
||||
if (expression is IdentifierNameSyntax or
|
||||
InvocationExpressionSyntax or
|
||||
InterpolatedStringExpressionSyntax or
|
||||
BinaryExpressionSyntax)
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(DynamicLabelRule, expression.GetLocation());
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Testing;
|
||||
using Xunit;
|
||||
using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier<StellaOps.Telemetry.Analyzers.MetricLabelAnalyzer>;
|
||||
|
||||
namespace StellaOps.Telemetry.Analyzers.Tests;
|
||||
|
||||
public sealed class MetricLabelAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ValidLabelKey_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status_code", "200"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidLabelKey_UpperCase_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag({|#0:"StatusCode"|}, "200"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.InvalidLabelKeyDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("StatusCode");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HighCardinalityLabelKey_UserId_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag({|#0:"user_id"|}, "123"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.HighCardinalityDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("user_id");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HighCardinalityLabelKey_RequestId_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void IncrementRequests(params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.IncrementRequests(GoldenSignalMetrics.Tag({|#0:"request_id"|}, "abc-123"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.HighCardinalityDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("request_id");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HighCardinalityLabelKey_Email_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void IncrementErrors(params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.IncrementErrors(GoldenSignalMetrics.Tag({|#0:"user_email"|}, "test@example.com"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.HighCardinalityDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("user_email");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicLabelValue_Variable_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(string dynamicValue)
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("operation", {|#0:dynamicValue|}));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.DynamicLabelDiagnosticId)
|
||||
.WithLocation(0);
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicLabelValue_InterpolatedString_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(int code)
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", {|#0:$"code_{code}"|}));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.DynamicLabelDiagnosticId)
|
||||
.WithLocation(0);
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticLabelValue_Constant_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
private const string StatusOk = "ok";
|
||||
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", StatusOk));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnumLabelValue_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public enum Status { Ok, Error }
|
||||
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", Status.Ok));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnumToStringLabelValue_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public enum Status { Ok, Error }
|
||||
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", Status.Ok.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TupleSyntax_ValidLabel_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(Counter<int> counter)
|
||||
{
|
||||
counter.Add(1, ("status_code", "200"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeyValuePairCreation_HighCardinalityKey_ReportsDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(Counter<int> counter)
|
||||
{
|
||||
counter.Add(1, new KeyValuePair<string, object?>({|#0:"session_id"|}, "abc"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected = Verifier.Diagnostic(MetricLabelAnalyzer.HighCardinalityDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("session_id");
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonMetricMethod_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class RegularClass
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void SomeMethod(params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var obj = new RegularClass();
|
||||
obj.SomeMethod(RegularClass.Tag("user_id", "123"));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleIssues_ReportsAllDiagnostics()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod(string dynamicValue)
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0,
|
||||
GoldenSignalMetrics.Tag({|#0:"UserId"|}, "static"),
|
||||
GoldenSignalMetrics.Tag("operation", {|#1:dynamicValue|}));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var expected1 = Verifier.Diagnostic(MetricLabelAnalyzer.InvalidLabelKeyDiagnosticId)
|
||||
.WithLocation(0)
|
||||
.WithArguments("UserId");
|
||||
|
||||
var expected2 = Verifier.Diagnostic(MetricLabelAnalyzer.DynamicLabelDiagnosticId)
|
||||
.WithLocation(1);
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test, expected1, expected2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticReadonlyField_LabelValue_NoDiagnostic()
|
||||
{
|
||||
var test = """
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace TestNamespace
|
||||
{
|
||||
public class GoldenSignalMetrics
|
||||
{
|
||||
public static KeyValuePair<string, object?> Tag(string key, object? value) => new(key, value);
|
||||
public void RecordLatency(double value, params KeyValuePair<string, object?>[] tags) { }
|
||||
}
|
||||
|
||||
public static class Labels
|
||||
{
|
||||
public static readonly string StatusOk = "ok";
|
||||
}
|
||||
|
||||
public class TestClass
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var metrics = new GoldenSignalMetrics();
|
||||
metrics.RecordLatency(100.0, GoldenSignalMetrics.Tag("status", Labels.StatusOk));
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier.VerifyAnalyzerAsync(test);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Telemetry.Analyzers.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Description>Roslyn analyzers for StellaOps telemetry code quality, including metric label validation and cardinality guards.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.11.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user