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
+ }
+ }
+}