diff --git a/src/Sentry.Compiler.Extensions/AnalyzerReleases.Shipped.md b/src/Sentry.Compiler.Extensions/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000000..60b59dd99b --- /dev/null +++ b/src/Sentry.Compiler.Extensions/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/src/Sentry.Compiler.Extensions/AnalyzerReleases.Unshipped.md b/src/Sentry.Compiler.Extensions/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000000..f8fc0f9c86 --- /dev/null +++ b/src/Sentry.Compiler.Extensions/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +SENTRY1001 | Support | Warning | TraceConnectedMetricsAnalyzer \ No newline at end of file diff --git a/src/Sentry.Compiler.Extensions/Analyzers/TraceConnectedMetricsAnalyzer.cs b/src/Sentry.Compiler.Extensions/Analyzers/TraceConnectedMetricsAnalyzer.cs new file mode 100644 index 0000000000..52070d0907 --- /dev/null +++ b/src/Sentry.Compiler.Extensions/Analyzers/TraceConnectedMetricsAnalyzer.cs @@ -0,0 +1,113 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Sentry.Compiler.Extensions.Analyzers; + +/// +/// Guide consumers to use the public API of Sentry Trace-connected Metrics correctly. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TraceConnectedMetricsAnalyzer : DiagnosticAnalyzer +{ + private static readonly string Title = "Unsupported numeric type of Metric"; + private static readonly string MessageFormat = "{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double."; + private static readonly string Description = "Integers should be a 64-bit signed integer, while doubles should be a 64-bit floating point number."; + + private static readonly DiagnosticDescriptor Rule = new( + id: DiagnosticIds.Sentry1001, + title: Title, + messageFormat: MessageFormat, + category: DiagnosticCategories.Support, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Description, + helpLinkUri: null + ); + + /// + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(Rule); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterOperationAction(Execute, OperationKind.Invocation); + } + + private static void Execute(OperationAnalysisContext context) + { + Debug.Assert(context.Operation.Language == LanguageNames.CSharp); + Debug.Assert(context.Operation.Kind is OperationKind.Invocation); + + context.CancellationToken.ThrowIfCancellationRequested(); + + if (context.Operation is not IInvocationOperation invocation) + { + return; + } + + var method = invocation.TargetMethod; + if (method.DeclaredAccessibility != Accessibility.Public || method.IsStatic || method.Parameters.Length == 0) + { + return; + } + + if (!method.IsGenericMethod || method.Arity != 1 || method.TypeArguments.Length != 1) + { + return; + } + + if (method.ContainingAssembly is null || method.ContainingAssembly.Name != "Sentry") + { + return; + } + + if (method.ContainingNamespace is null || method.ContainingNamespace.Name != "Sentry") + { + return; + } + + string fullyQualifiedMetadataName; + if (method.Name is "EmitCounter" or "EmitGauge" or "EmitDistribution") + { + fullyQualifiedMetadataName = "Sentry.SentryMetricEmitter"; + } + else if (method.Name is "TryGetValue") + { + fullyQualifiedMetadataName = "Sentry.SentryMetric"; + } + else + { + return; + } + + var typeArgument = method.TypeArguments[0]; + if (typeArgument.SpecialType is SpecialType.System_Byte or SpecialType.System_Int16 or SpecialType.System_Int32 or SpecialType.System_Int64 or SpecialType.System_Single or SpecialType.System_Double) + { + return; + } + + if (typeArgument is ITypeParameterSymbol) + { + return; + } + + var sentryType = context.Compilation.GetTypeByMetadataName(fullyQualifiedMetadataName); + if (sentryType is null) + { + return; + } + + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, sentryType)) + { + return; + } + + var location = invocation.Syntax.GetLocation(); + var diagnostic = Diagnostic.Create(Rule, location, typeArgument.ToDisplayString(SymbolDisplayFormats.FullNameFormat)); + context.ReportDiagnostic(diagnostic); + } +} diff --git a/src/Sentry.Compiler.Extensions/DiagnosticCategories.cs b/src/Sentry.Compiler.Extensions/DiagnosticCategories.cs new file mode 100644 index 0000000000..3fe408be60 --- /dev/null +++ b/src/Sentry.Compiler.Extensions/DiagnosticCategories.cs @@ -0,0 +1,6 @@ +namespace Sentry.Compiler.Extensions; + +internal static class DiagnosticCategories +{ + internal const string Support = nameof(Support); +} diff --git a/src/Sentry.Compiler.Extensions/DiagnosticIds.cs b/src/Sentry.Compiler.Extensions/DiagnosticIds.cs new file mode 100644 index 0000000000..fa2f8a3d94 --- /dev/null +++ b/src/Sentry.Compiler.Extensions/DiagnosticIds.cs @@ -0,0 +1,6 @@ +namespace Sentry.Compiler.Extensions; + +internal static class DiagnosticIds +{ + internal const string Sentry1001 = "SENTRY1001"; +} diff --git a/src/Sentry.Compiler.Extensions/Sentry.Compiler.Extensions.csproj b/src/Sentry.Compiler.Extensions/Sentry.Compiler.Extensions.csproj index 9fb31748a3..4194c4e42f 100644 --- a/src/Sentry.Compiler.Extensions/Sentry.Compiler.Extensions.csproj +++ b/src/Sentry.Compiler.Extensions/Sentry.Compiler.Extensions.csproj @@ -15,11 +15,36 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + + + true + + + + + $([System.String]::Copy('$(DefineConstants)').Replace('FeatureMemory','').Replace(';;',';')) + + + + + + + + + diff --git a/src/Sentry.Compiler.Extensions/SymbolDisplayFormats.cs b/src/Sentry.Compiler.Extensions/SymbolDisplayFormats.cs new file mode 100644 index 0000000000..85af2df64b --- /dev/null +++ b/src/Sentry.Compiler.Extensions/SymbolDisplayFormats.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; + +namespace Sentry.Compiler.Extensions; + +internal static class SymbolDisplayFormats +{ + internal static SymbolDisplayFormat FullNameFormat { get; } = new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters + ); +} diff --git a/test/Sentry.Compiler.Extensions.Tests/Analyzers/TraceConnectedMetricsAnalyzerTests.cs b/test/Sentry.Compiler.Extensions.Tests/Analyzers/TraceConnectedMetricsAnalyzerTests.cs new file mode 100644 index 0000000000..8b3c255f25 --- /dev/null +++ b/test/Sentry.Compiler.Extensions.Tests/Analyzers/TraceConnectedMetricsAnalyzerTests.cs @@ -0,0 +1,275 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Sentry.Compiler.Extensions.Analyzers; + +using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier; + +namespace Sentry.Compiler.Extensions.Tests.Analyzers; + +public class TraceConnectedMetricsAnalyzerTests +{ + [Fact] + public async Task NoCode_NoDiagnostics() + { + await Verifier.VerifyAnalyzerAsync(""); + } + + [Fact] + public async Task NoInvocations_NoDiagnostics() + { + var test = new CSharpAnalyzerTest + { + SolutionTransforms = { SolutionTransforms.Nullable }, + TestState = + { + ReferenceAssemblies = ReferenceAssemblies.Current, + AdditionalReferences = { typeof(SentryMetricEmitter).Assembly }, + Sources = + { + """ + #nullable disable + using System; + using Sentry; + + public class AnalyzerTest + { + public void Init(SentryOptions options) + { + options.Experimental.EnableMetrics = false; + } + + public void Emit(IHub hub) + { + var metrics = SentrySdk.Experimental.Metrics; + + _ = metrics.GetType(); + + #pragma warning disable SENTRYTRACECONNECTEDMETRICS + _ = hub.Metrics.GetType(); + #pragma warning restore SENTRYTRACECONNECTEDMETRICS + + _ = SentrySdk.Experimental.Metrics.Equals(null); + _ = SentrySdk.Experimental.Metrics.GetHashCode(); + _ = SentrySdk.Experimental.Metrics.GetType(); + _ = SentrySdk.Experimental.Metrics.ToString(); + } + } + """ + }, + ExpectedDiagnostics = { }, + }, + }; + + await test.RunAsync(); + } + + [Fact] + public async Task SupportedInvocations_NoDiagnostics() + { + var test = new CSharpAnalyzerTest + { + SolutionTransforms = { SolutionTransforms.Nullable }, + TestState = + { + ReferenceAssemblies = ReferenceAssemblies.Current, + AdditionalReferences = { typeof(SentryMetricEmitter).Assembly }, + Sources = + { + """ + #nullable enable + using System; + using Sentry; + + public class AnalyzerTest + { + public void Init(SentryOptions options) + { + options.Experimental.SetBeforeSendMetric(static SentryMetric? (SentryMetric metric) => metric.TryGetValue(out _) ? metric : null); + options.Experimental.SetBeforeSendMetric(OnBeforeSendMetric); + options.Experimental.SetBeforeSendMetric(OnBeforeSendMetric); + } + + public void Emit(IHub hub) + { + var scope = new Scope(new SentryOptions()); + var metrics = SentrySdk.Experimental.Metrics; + + #pragma warning disable SENTRYTRACECONNECTEDMETRICS + metrics.EmitCounter("name", 1); + hub.Metrics.EmitCounter("name", 1f); + SentrySdk.Experimental.Metrics.EmitCounter("name", 1.1d, [], scope); + + metrics.EmitGauge("name", 2); + hub.Metrics.EmitGauge("name", 2f); + SentrySdk.Experimental.Metrics.EmitGauge("name", 2.2d, MeasurementUnit.Custom("unit"), [], scope); + + metrics.EmitDistribution("name", 3); + hub.Metrics.EmitDistribution("name", 3f); + SentrySdk.Experimental.Metrics.EmitDistribution("name", 3.3d, MeasurementUnit.Custom("unit"), [], scope); + #pragma warning restore SENTRYTRACECONNECTEDMETRICS + } + + private static SentryMetric? OnBeforeSendMetric(SentryMetric metric) + { + if (metric.TryGetValue(out _)) + { + return metric; + } + + return null; + } + + private static SentryMetric? OnBeforeSendMetric(SentryMetric metric) where T : struct + { + if (metric.TryGetValue(out _)) + { + return metric; + } + + return null; + } + } + + public static class Extensions + { + public static void EmitCounter(this SentryMetricEmitter metrics) where T : struct + { + metrics.EmitCounter("default", default(T), [], (Scope?)null); + } + + public static void EmitCounter(this SentryMetricEmitter metrics, string name) where T : struct + { + metrics.EmitCounter(name, default(T), [], (Scope?)null); + } + + public static void EmitGauge(this SentryMetricEmitter metrics) where T : struct + { + metrics.EmitGauge("default", default(T), default(MeasurementUnit), [], (Scope?)null); + } + + public static void EmitGauge(this SentryMetricEmitter metrics, string name) where T : struct + { + metrics.EmitGauge(name, default(T), default(MeasurementUnit), [], (Scope?)null); + } + + public static void EmitDistribution(this SentryMetricEmitter metrics) where T : struct + { + metrics.EmitDistribution("default", default(T), default(MeasurementUnit), [], (Scope?)null); + } + + public static void EmitDistribution(this SentryMetricEmitter metrics, string name) where T : struct + { + metrics.EmitDistribution(name, default(T), default(MeasurementUnit), [], (Scope?)null); + } + } + """ + }, + ExpectedDiagnostics = { }, + }, + }; + + await test.RunAsync(); + } + + [Fact] + public async Task UnsupportedInvocations_ReportDiagnostics() + { + var test = new CSharpAnalyzerTest + { + SolutionTransforms = { SolutionTransforms.Nullable }, + TestState = + { + ReferenceAssemblies = ReferenceAssemblies.Current, + AdditionalReferences = { typeof(SentryMetricEmitter).Assembly }, + Sources = + { + """ + #nullable enable + using System; + using Sentry; + + public class AnalyzerTest + { + public void Init(SentryOptions options) + { + options.Experimental.SetBeforeSendMetric(static SentryMetric? (SentryMetric metric) => {|#0:metric.TryGetValue(out _)|#0} ? metric : null); + options.Experimental.SetBeforeSendMetric(OnBeforeSendMetric); + options.Experimental.SetBeforeSendMetric(OnBeforeSendMetric); + } + + public void Emit(IHub hub) + { + var scope = new Scope(new SentryOptions()); + var metrics = SentrySdk.Experimental.Metrics; + + #pragma warning disable SENTRYTRACECONNECTEDMETRICS + {|#10:metrics.EmitCounter("name", (uint)1)|#10}; + {|#11:hub.Metrics.EmitCounter("name", (StringComparison)1f)|#11}; + {|#12:SentrySdk.Experimental.Metrics.EmitCounter("name", 1.1m, [], scope)|#12}; + + {|#13:metrics.EmitGauge("name", (uint)2)|#13}; + {|#14:hub.Metrics.EmitGauge("name", (StringComparison)2f)|#14}; + {|#15:SentrySdk.Experimental.Metrics.EmitGauge("name", 2.2m, MeasurementUnit.Custom("unit"), [], scope)|#15}; + + {|#16:metrics.EmitDistribution("name", (uint)3)|#16}; + {|#17:hub.Metrics.EmitDistribution("name", (StringComparison)3f)|#17}; + {|#18:SentrySdk.Experimental.Metrics.EmitDistribution("name", 3.3m, MeasurementUnit.Custom("unit"), [], scope)|#18}; + #pragma warning restore SENTRYTRACECONNECTEDMETRICS + } + + private static SentryMetric? OnBeforeSendMetric(SentryMetric metric) + { + if ({|#1:metric.TryGetValue(out _)|#1}) + { + return metric; + } + + return null; + } + + private static SentryMetric? OnBeforeSendMetric(SentryMetric metric) where T : struct + { + if (metric.TryGetValue(out _)) + { + return metric; + } + + return null; + } + } + """ + }, + ExpectedDiagnostics = + { + CreateDiagnostic(0, typeof(sbyte)), + CreateDiagnostic(1, typeof(ushort)), + + CreateDiagnostic(10, typeof(uint)), + CreateDiagnostic(11, typeof(StringComparison)), + CreateDiagnostic(12, typeof(decimal)), + CreateDiagnostic(13, typeof(uint)), + CreateDiagnostic(14, typeof(StringComparison)), + CreateDiagnostic(15, typeof(decimal)), + CreateDiagnostic(16, typeof(uint)), + CreateDiagnostic(17, typeof(StringComparison)), + CreateDiagnostic(18, typeof(decimal)), + }, + }, + }; + + await test.RunAsync(); + } + + private static DiagnosticResult CreateDiagnostic(int markupKey, Type type) + { + Assert.NotNull(type.FullName); + + return Verifier.Diagnostic("SENTRY1001") + .WithSeverity(DiagnosticSeverity.Warning) + .WithArguments(type.FullName) + .WithMessage($"{type.FullName} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double.") + .WithMessageFormat("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double.") + .WithLocation(markupKey); + } +} diff --git a/test/Sentry.Compiler.Extensions.Tests/Sentry.Compiler.Extensions.Tests.csproj b/test/Sentry.Compiler.Extensions.Tests/Sentry.Compiler.Extensions.Tests.csproj index 3265c3c21c..e857d98a44 100644 --- a/test/Sentry.Compiler.Extensions.Tests/Sentry.Compiler.Extensions.Tests.csproj +++ b/test/Sentry.Compiler.Extensions.Tests/Sentry.Compiler.Extensions.Tests.csproj @@ -7,16 +7,21 @@ - - - - + + + + - + + + + + + diff --git a/test/Sentry.Compiler.Extensions.Tests/Testing/ReferenceAssembliesExtensions.cs b/test/Sentry.Compiler.Extensions.Tests/Testing/ReferenceAssembliesExtensions.cs new file mode 100644 index 0000000000..d0e40515b3 --- /dev/null +++ b/test/Sentry.Compiler.Extensions.Tests/Testing/ReferenceAssembliesExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.CodeAnalysis.Testing; + +namespace Sentry.Compiler.Extensions.Tests.Testing; + +internal static class ReferenceAssembliesExtensions +{ + extension(ReferenceAssemblies) + { + internal static ReferenceAssemblies Current + { + get + { +#if NET8_0 + return ReferenceAssemblies.Net.Net80; +#elif NET9_0 + return ReferenceAssemblies.Net.Net90; +#elif NET10_0 + return ReferenceAssemblies.Net.Net100; +#else +#warning Target Framework not implemented. + throw new UnreachableException(); +#endif + } + } + } +} diff --git a/test/Sentry.Compiler.Extensions.Tests/Testing/SolutionTransforms.cs b/test/Sentry.Compiler.Extensions.Tests/Testing/SolutionTransforms.cs new file mode 100644 index 0000000000..81839cf5de --- /dev/null +++ b/test/Sentry.Compiler.Extensions.Tests/Testing/SolutionTransforms.cs @@ -0,0 +1,37 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Sentry.Compiler.Extensions.Tests.Testing; + +internal static class SolutionTransforms +{ + private static readonly ImmutableDictionary s_nullableWarnings = GetNullableWarningsFromCompiler(); + + internal static Func Nullable { get; } = static (solution, projectId) => + { + var project = solution.GetProject(projectId); + Assert.NotNull(project); + + var compilationOptions = project.CompilationOptions; + Assert.NotNull(compilationOptions); + + compilationOptions = compilationOptions.WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions.SetItems(s_nullableWarnings)); + + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions); + return solution; + }; + + private static ImmutableDictionary GetNullableWarningsFromCompiler() + { + string[] args = { "/warnaserror:nullable" }; + var commandLineArguments = CSharpCommandLineParser.Default.Parse(args, Environment.CurrentDirectory, Environment.CurrentDirectory, null); + var nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions; + + // Workaround for https://github.com/dotnet/roslyn/issues/41610 + nullableWarnings = nullableWarnings + .SetItem("CS8632", ReportDiagnostic.Error) + .SetItem("CS8669", ReportDiagnostic.Error); + + return nullableWarnings; + } +}