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..ff8dd50f95 --- /dev/null +++ b/src/Sentry.Compiler.Extensions/Analyzers/TraceConnectedMetricsAnalyzer.cs @@ -0,0 +1,114 @@ +using System; +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.IsAbstract || method.IsVirtual || method.IsStatic || !method.ReturnsVoid || 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.SentryTraceMetrics"; + } + else if (method.Name is "SetBeforeSendMetric") + { + fullyQualifiedMetadataName = "Sentry.SentryOptions+ExperimentalSentryOptions"; + } + 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..8e5a6c88aa 100644 --- a/src/Sentry.Compiler.Extensions/Sentry.Compiler.Extensions.csproj +++ b/src/Sentry.Compiler.Extensions/Sentry.Compiler.Extensions.csproj @@ -15,11 +15,25 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + + + + + + 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..a07f701b55 --- /dev/null +++ b/test/Sentry.Compiler.Extensions.Tests/Analyzers/TraceConnectedMetricsAnalyzerTests.cs @@ -0,0 +1,252 @@ +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 + { + TestState = + { + ReferenceAssemblies = TargetFramework.ReferenceAssemblies, + AdditionalReferences = { typeof(SentryTraceMetrics).Assembly }, + Sources = + { + """ + #nullable enable + 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 + { + TestState = + { + ReferenceAssemblies = TargetFramework.ReferenceAssemblies, + AdditionalReferences = { typeof(SentryTraceMetrics).Assembly }, + Sources = + { + """ + #nullable enable + using Sentry; + + public class AnalyzerTest + { + public void Init(SentryOptions options) + { + options.Experimental.SetBeforeSendMetric(static SentryMetric? (SentryMetric metric) => metric); + options.Experimental.SetBeforeSendMetric(BeforeSendMetric); + 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, "unit", [], scope); + + metrics.EmitDistribution("name", 3); + hub.Metrics.EmitDistribution("name", 3f); + SentrySdk.Experimental.Metrics.EmitDistribution("name", 3.3d, "unit", [], scope); + #pragma warning restore SENTRYTRACECONNECTEDMETRICS + } + + private static SentryMetric? BeforeSendMetric(SentryMetric metric) where T : struct + { + return metric; + } + + private static SentryMetric? OnBeforeSendMetric(SentryMetric metric) + { + return metric; + } + } + + public static class Extensions + { + public static void EmitCounter(this SentryTraceMetrics metrics) where T : struct + { + metrics.EmitCounter("default", default(T), [], null); + } + + public static void EmitCounter(this SentryTraceMetrics metrics, string name) where T : struct + { + metrics.EmitCounter(name, default(T), [], null); + } + + public static void EmitGauge(this SentryTraceMetrics metrics) where T : struct + { + metrics.EmitGauge("default", default(T), null, [], null); + } + + public static void EmitGauge(this SentryTraceMetrics metrics, string name) where T : struct + { + metrics.EmitGauge(name, default(T), null, [], null); + } + + public static void EmitDistribution(this SentryTraceMetrics metrics) where T : struct + { + metrics.EmitDistribution("default", default(T), null, [], null); + } + + public static void EmitDistribution(this SentryTraceMetrics metrics, string name) where T : struct + { + metrics.EmitDistribution(name, default(T), null, [], null); + } + } + """ + }, + ExpectedDiagnostics = { }, + }, + SolutionTransforms = { SolutionTransforms.Nullable }, + }; + + await test.RunAsync(); + } + + [Fact] + public async Task UnsupportedInvocations_ReportDiagnostics() + { + var test = new CSharpAnalyzerTest + { + TestState = + { + ReferenceAssemblies = TargetFramework.ReferenceAssemblies, + AdditionalReferences = { typeof(SentryTraceMetrics).Assembly }, + Sources = + { + """ + #nullable enable + using System; + using Sentry; + + public class AnalyzerTest + { + public void Init(SentryOptions options) + { + {|#0:options.Experimental.SetBeforeSendMetric(static SentryMetric? (SentryMetric metric) => metric)|#0}; + {|#1:options.Experimental.SetBeforeSendMetric(BeforeSendMetric)|#1}; + {|#2:options.Experimental.SetBeforeSendMetric(OnBeforeSendMetric)|#2}; + } + + 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, "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, "unit", [], scope)|#18}; + #pragma warning restore SENTRYTRACECONNECTEDMETRICS + } + + private static SentryMetric? BeforeSendMetric(SentryMetric metric) where T : struct + { + return metric; + } + + private static SentryMetric? OnBeforeSendMetric(SentryMetric metric) + { + return metric; + } + } + """ + }, + ExpectedDiagnostics = + { + CreateDiagnostic(0, typeof(sbyte)), + CreateDiagnostic(1, typeof(ushort)), + CreateDiagnostic(2, typeof(ulong)), + + 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..e005a20dc9 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,20 @@ - - - - + + + + - + + + + + diff --git a/test/Sentry.Compiler.Extensions.Tests/SolutionTransforms.cs b/test/Sentry.Compiler.Extensions.Tests/SolutionTransforms.cs new file mode 100644 index 0000000000..bc4bfb44ce --- /dev/null +++ b/test/Sentry.Compiler.Extensions.Tests/SolutionTransforms.cs @@ -0,0 +1,37 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Sentry.Compiler.Extensions.Tests; + +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; + } +} diff --git a/test/Sentry.Compiler.Extensions.Tests/TargetFramework.cs b/test/Sentry.Compiler.Extensions.Tests/TargetFramework.cs new file mode 100644 index 0000000000..3e8191b8c4 --- /dev/null +++ b/test/Sentry.Compiler.Extensions.Tests/TargetFramework.cs @@ -0,0 +1,23 @@ +using Microsoft.CodeAnalysis.Testing; + +namespace Sentry.Compiler.Extensions.Tests; + +internal static class TargetFramework +{ + internal static ReferenceAssemblies ReferenceAssemblies + { + 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 NotImplementedException(); +#endif + } + } +}