diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index a9ce261c08..8fd038518e 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -11,6 +11,15 @@ runs: echo "DOTNET_CLI_TELEMETRY_OPTOUT=1" >> $GITHUB_ENV echo "DOTNET_NOLOGO=1" >> $GITHUB_ENV + # Needed for Android SDK setup step + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@07976c6290703d34c16d382cb36445f98bb43b1f # v3.2.0 + - name: Set Java Version uses: actions/setup-java@v3 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 396bb56a9a..64d7f223ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -180,6 +180,15 @@ jobs: with: dotnet-version: 8.0.x + # Needed for Android SDK setup step + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@07976c6290703d34c16d382cb36445f98bb43b1f # v3.2.0 + - run: dotnet workload install android maui-android - name: Test diff --git a/.github/workflows/device-tests-android.yml b/.github/workflows/device-tests-android.yml index 37358b96e1..a82e7e8dc2 100644 --- a/.github/workflows/device-tests-android.yml +++ b/.github/workflows/device-tests-android.yml @@ -25,12 +25,6 @@ jobs: with: submodules: recursive - - name: Set Java Version - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "11" - - name: Setup Environment uses: ./.github/actions/environment diff --git a/CHANGELOG.md b/CHANGELOG.md index 469b6f464c..fa62dda90f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Significant change in behavior + +- Transactions' spans are no longer automatically finished with status `deadline_exceeded` by the transaction. This is now handled by the [Relay](https://github.com/getsentry/relay). + - Customers self hosting Sentry must use verion 22.12.0 or later ([#3013](https://github.com/getsentry/sentry-dotnet/pull/3013)) + ### API breaking Changes #### Changed APIs @@ -11,14 +16,18 @@ ### Dependencies -- Bump CLI from v2.23.2 to v2.24.1 ([#3012](https://github.com/getsentry/sentry-dotnet/pull/3012)) - - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2241) - - [diff](https://github.com/getsentry/sentry-cli/compare/2.23.2...2.24.1) +- Bump CLI from v2.23.2 to v2.25.0 ([#3012](https://github.com/getsentry/sentry-dotnet/pull/3012), [#3030](https://github.com/getsentry/sentry-dotnet/pull/3030)) + - [changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md#2250) + - [diff](https://github.com/getsentry/sentry-cli/compare/2.23.2...2.25.0) +- Bump Native SDK from v0.6.7 to v0.7.0 ([#3029](https://github.com/getsentry/sentry-dotnet/pull/3029)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#070) + - [diff](https://github.com/getsentry/sentry-native/compare/0.6.7...0.7.0) ## 4.0.0-beta.7 ### Features +- Experimental pre-release availability of Metrics. We're exploring the use of Metrics in Sentry. The API will very likely change and we don't yet have any documentation. ([#2949](https://github.com/getsentry/sentry-dotnet/pull/2949)) - MAUI Screenshot support. You can opt-in via `SentryMauiOptions.AttachScreenshots` ([#2965](https://github.com/getsentry/sentry-dotnet/pull/2965)) - Supports Android and iOS only. Windows is not supported. - MAUI: App context has `in_foreground` indicating whether app was on the background or foreground. ([#2983](https://github.com/getsentry/sentry-dotnet/pull/2983)) diff --git a/Directory.Build.props b/Directory.Build.props index a35325cc78..d1af4dc52a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -87,7 +87,7 @@ - 2.24.1 + 2.25.0 $(MSBuildThisFileDirectory)tools\sentry-cli\$(SentryCLIVersion)\ diff --git a/Sentry-CI-Build-Linux.slnf b/Sentry-CI-Build-Linux.slnf index f48a6c3ca8..ea8698b8d9 100644 --- a/Sentry-CI-Build-Linux.slnf +++ b/Sentry-CI-Build-Linux.slnf @@ -14,6 +14,7 @@ "samples\\Sentry.Samples.Azure.Functions.Worker\\Sentry.Samples.Azure.Functions.Worker.csproj", "samples\\Sentry.Samples.Console.Basic\\Sentry.Samples.Console.Basic.csproj", "samples\\Sentry.Samples.Console.Customized\\Sentry.Samples.Console.Customized.csproj", + "samples\\Sentry.Samples.Console.Metrics\\Sentry.Samples.Console.Metrics.csproj", "samples\\Sentry.Samples.Console.Native\\Sentry.Samples.Console.Native.csproj", "samples\\Sentry.Samples.Console.Profiling\\Sentry.Samples.Console.Profiling.csproj", "samples\\Sentry.Samples.EntityFramework\\Sentry.Samples.EntityFramework.csproj", diff --git a/Sentry-CI-Build-Windows.slnf b/Sentry-CI-Build-Windows.slnf index e08b88d7f9..4d8b52312c 100644 --- a/Sentry-CI-Build-Windows.slnf +++ b/Sentry-CI-Build-Windows.slnf @@ -13,6 +13,7 @@ "samples\\Sentry.Samples.Azure.Functions.Worker\\Sentry.Samples.Azure.Functions.Worker.csproj", "samples\\Sentry.Samples.Console.Basic\\Sentry.Samples.Console.Basic.csproj", "samples\\Sentry.Samples.Console.Customized\\Sentry.Samples.Console.Customized.csproj", + "samples\\Sentry.Samples.Console.Metrics\\Sentry.Samples.Console.Metrics.csproj", "samples\\Sentry.Samples.Console.Native\\Sentry.Samples.Console.Native.csproj", "samples\\Sentry.Samples.Console.Profiling\\Sentry.Samples.Console.Profiling.csproj", "samples\\Sentry.Samples.EntityFramework\\Sentry.Samples.EntityFramework.csproj", diff --git a/Sentry-CI-Build-macOS.slnf b/Sentry-CI-Build-macOS.slnf index 336e58f89e..3bb3a387d6 100644 --- a/Sentry-CI-Build-macOS.slnf +++ b/Sentry-CI-Build-macOS.slnf @@ -15,6 +15,7 @@ "samples\\Sentry.Samples.Azure.Functions.Worker\\Sentry.Samples.Azure.Functions.Worker.csproj", "samples\\Sentry.Samples.Console.Basic\\Sentry.Samples.Console.Basic.csproj", "samples\\Sentry.Samples.Console.Customized\\Sentry.Samples.Console.Customized.csproj", + "samples\\Sentry.Samples.Console.Metrics\\Sentry.Samples.Console.Metrics.csproj", "samples\\Sentry.Samples.Console.Native\\Sentry.Samples.Console.Native.csproj", "samples\\Sentry.Samples.Console.Profiling\\Sentry.Samples.Console.Profiling.csproj", "samples\\Sentry.Samples.EntityFramework\\Sentry.Samples.EntityFramework.csproj", diff --git a/Sentry.NoMobile.sln b/Sentry.NoMobile.sln index ac60374e4b..3b32ad1694 100644 --- a/Sentry.NoMobile.sln +++ b/Sentry.NoMobile.sln @@ -163,6 +163,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FastSerialization", "module EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.Console.Native", "samples\Sentry.Samples.Console.Native\Sentry.Samples.Console.Native.csproj", "{FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.Console.Metrics", "samples\Sentry.Samples.Console.Metrics\Sentry.Samples.Console.Metrics.csproj", "{BD2D08FC-8675-4157-A73C-D75F6A3856D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -465,6 +467,10 @@ Global {FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2}.Debug|Any CPU.Build.0 = Debug|Any CPU {FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2}.Release|Any CPU.ActiveCfg = Release|Any CPU {FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2}.Release|Any CPU.Build.0 = Release|Any CPU + {BD2D08FC-8675-4157-A73C-D75F6A3856D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD2D08FC-8675-4157-A73C-D75F6A3856D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD2D08FC-8675-4157-A73C-D75F6A3856D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD2D08FC-8675-4157-A73C-D75F6A3856D3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -545,5 +551,6 @@ Global {67269916-C417-4CEE-BD7D-CA66C3830AEE} = {A3CCA27E-4DF8-479D-833C-CAA0950715AA} {8032310D-3C06-442C-A318-F365BCC4C804} = {A3CCA27E-4DF8-479D-833C-CAA0950715AA} {FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2} = {21B42F60-5802-404E-90F0-AEBCC56760C0} + {BD2D08FC-8675-4157-A73C-D75F6A3856D3} = {21B42F60-5802-404E-90F0-AEBCC56760C0} EndGlobalSection EndGlobal diff --git a/Sentry.sln b/Sentry.sln index ac60374e4b..3b32ad1694 100644 --- a/Sentry.sln +++ b/Sentry.sln @@ -163,6 +163,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FastSerialization", "module EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.Console.Native", "samples\Sentry.Samples.Console.Native\Sentry.Samples.Console.Native.csproj", "{FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.Console.Metrics", "samples\Sentry.Samples.Console.Metrics\Sentry.Samples.Console.Metrics.csproj", "{BD2D08FC-8675-4157-A73C-D75F6A3856D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -465,6 +467,10 @@ Global {FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2}.Debug|Any CPU.Build.0 = Debug|Any CPU {FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2}.Release|Any CPU.ActiveCfg = Release|Any CPU {FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2}.Release|Any CPU.Build.0 = Release|Any CPU + {BD2D08FC-8675-4157-A73C-D75F6A3856D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD2D08FC-8675-4157-A73C-D75F6A3856D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD2D08FC-8675-4157-A73C-D75F6A3856D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD2D08FC-8675-4157-A73C-D75F6A3856D3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -545,5 +551,6 @@ Global {67269916-C417-4CEE-BD7D-CA66C3830AEE} = {A3CCA27E-4DF8-479D-833C-CAA0950715AA} {8032310D-3C06-442C-A318-F365BCC4C804} = {A3CCA27E-4DF8-479D-833C-CAA0950715AA} {FC8AEABA-1A40-4891-9EBA-4B6A1F7244B2} = {21B42F60-5802-404E-90F0-AEBCC56760C0} + {BD2D08FC-8675-4157-A73C-D75F6A3856D3} = {21B42F60-5802-404E-90F0-AEBCC56760C0} EndGlobalSection EndGlobal diff --git a/SentryAspNetCore.slnf b/SentryAspNetCore.slnf index d6a9162ed9..a07e91ff14 100644 --- a/SentryAspNetCore.slnf +++ b/SentryAspNetCore.slnf @@ -15,11 +15,13 @@ "src\\Sentry.DiagnosticSource\\Sentry.DiagnosticSource.csproj", "src\\Sentry.Extensions.Logging\\Sentry.Extensions.Logging.csproj", "src\\Sentry.OpenTelemetry\\Sentry.OpenTelemetry.csproj", + "src\\Sentry.Serilog\\Sentry.Serilog.csproj", "src\\Sentry\\Sentry.csproj", "test\\Sentry.AspNetCore.Grpc.Tests\\Sentry.AspNetCore.Grpc.Tests.csproj", "test\\Sentry.AspNetCore.Tests\\Sentry.AspNetCore.Tests.csproj", "test\\Sentry.AspNetCore.TestUtils\\Sentry.AspNetCore.TestUtils.csproj", "test\\Sentry.Extensions.Logging.Tests\\Sentry.Extensions.Logging.Tests.csproj", + "test\\Sentry.Testing.CrashableApp\\Sentry.Testing.CrashableApp.csproj", "test\\Sentry.Testing\\Sentry.Testing.csproj", "test\\Sentry.Tests\\Sentry.Tests.csproj" ] diff --git a/SentryNoMobile.slnf b/SentryNoMobile.slnf index effba4f145..3932f9c12f 100644 --- a/SentryNoMobile.slnf +++ b/SentryNoMobile.slnf @@ -13,6 +13,7 @@ "samples\\Sentry.Samples.Azure.Functions.Worker\\Sentry.Samples.Azure.Functions.Worker.csproj", "samples\\Sentry.Samples.Console.Basic\\Sentry.Samples.Console.Basic.csproj", "samples\\Sentry.Samples.Console.Customized\\Sentry.Samples.Console.Customized.csproj", + "samples\\Sentry.Samples.Console.Metrics\\Sentry.Samples.Console.Metrics.csproj", "samples\\Sentry.Samples.Console.Native\\Sentry.Samples.Console.Native.csproj", "samples\\Sentry.Samples.Console.Profiling\\Sentry.Samples.Console.Profiling.csproj", "samples\\Sentry.Samples.EntityFramework\\Sentry.Samples.EntityFramework.csproj", diff --git a/modules/Ben.Demystifier b/modules/Ben.Demystifier index dfdee44890..27c0910993 160000 --- a/modules/Ben.Demystifier +++ b/modules/Ben.Demystifier @@ -1 +1 @@ -Subproject commit dfdee448905e7685e56c6231768ea70ac2b20052 +Subproject commit 27c091099317f50d80b16ce306a56698d48a8430 diff --git a/modules/sentry-native b/modules/sentry-native index a3d58622a8..4ec95c0725 160000 --- a/modules/sentry-native +++ b/modules/sentry-native @@ -1 +1 @@ -Subproject commit a3d58622a807b9dda174cb9fc18fa0f98c89d043 +Subproject commit 4ec95c0725df5f34440db8fa8d37b4c519fce74e diff --git a/samples/Sentry.Samples.Console.Metrics/Program.cs b/samples/Sentry.Samples.Console.Metrics/Program.cs new file mode 100644 index 0000000000..defdb6daf4 --- /dev/null +++ b/samples/Sentry.Samples.Console.Metrics/Program.cs @@ -0,0 +1,107 @@ +using System.Numerics; + +namespace Sentry.Samples.Console.Metrics; + +internal static class Program +{ + private static readonly Random Roll = new(); + + private static void Main() + { + // Enable the SDK + using (SentrySdk.Init(options => + { + options.Dsn = + // NOTE: ADD YOUR OWN DSN BELOW so you can see the events in your own Sentry account + "https://eb18e953812b41c3aeb042e666fd3b5c@o447951.ingest.sentry.io/5428537"; + + options.Debug = true; + options.StackTraceMode = StackTraceMode.Enhanced; + // Initialize some (non null) ExperimentalMetricsOptions to enable Sentry Metrics, + options.ExperimentalMetrics = new ExperimentalMetricsOptions + { + EnableCodeLocations = + true // Set this to false if you don't want to track code locations for some reason + }; + })) + { + System.Console.WriteLine("Measure, Yeah, Measure!"); + while (true) + { + // Perform your task here + switch (Roll.Next(1,3)) + { + case 1: + PlaySetBingo(10); + break; + case 2: + CreateRevenueGauge(100); + break; + case 3: + MeasureShrimp(30); + break; + } + + + // Optional: Delay to prevent tight looping + var sleepTime = Roll.Next(1, 10); + System.Console.WriteLine($"Sleeping for {sleepTime} second(s)."); + System.Console.WriteLine("Press any key to stop..."); + Thread.Sleep(TimeSpan.FromSeconds(sleepTime)); + // Check if a key has been pressed + if (System.Console.KeyAvailable) + { + break; + } + } + System.Console.WriteLine("Measure up"); + } + } + + private static void PlaySetBingo(int attempts) + { + var solution = new[] { 3, 5, 7, 11, 13, 17 }; + + // The Timing class creates a distribution that is designed to measure the amount of time it takes to run code + // blocks. By default it will use a unit of Seconds - we're configuring it to use milliseconds here though. + using (new Timing("bingo", MeasurementUnit.Duration.Millisecond)) + { + for (var i = 0; i < attempts; i++) + { + var guess = Roll.Next(1, 100); + // This demonstrates the use of a set metric. + SentrySdk.Metrics.Gauge("guesses", guess); + + // And this is a counter + SentrySdk.Metrics.Increment(solution.Contains(guess) ? "correct_answers" : "incorrect_answers"); + } + } + } + + private static void CreateRevenueGauge(int sampleCount) + { + using (new Timing(nameof(CreateRevenueGauge), MeasurementUnit.Duration.Millisecond)) + { + for (var i = 0; i < sampleCount; i++) + { + var movement = Roll.NextDouble() * 30 - Roll.NextDouble() * 10; + // This demonstrates measuring something in your app using a gauge... we're also using a custom + // measurement unit here (which is optional - by default the unit will be "None") + SentrySdk.Metrics.Gauge("revenue", movement, MeasurementUnit.Custom("$")); + } + } + } + + private static void MeasureShrimp(int sampleCount) + { + using (new Timing(nameof(MeasureShrimp), MeasurementUnit.Duration.Millisecond)) + { + for (var i = 0; i < sampleCount; i++) + { + var sizeOfShrimp = 15 + Roll.NextDouble() * 30; + // This is an example of emitting a distribution metric + SentrySdk.Metrics.Distribution("shrimp.size", sizeOfShrimp, MeasurementUnit.Custom("cm")); + } + } + } +} diff --git a/samples/Sentry.Samples.Console.Metrics/Sentry.Samples.Console.Metrics.csproj b/samples/Sentry.Samples.Console.Metrics/Sentry.Samples.Console.Metrics.csproj new file mode 100644 index 0000000000..beb1ec147b --- /dev/null +++ b/samples/Sentry.Samples.Console.Metrics/Sentry.Samples.Console.Metrics.csproj @@ -0,0 +1,12 @@ + + + + Exe + net8.0 + + + + + + + diff --git a/scripts/device-test.ps1 b/scripts/device-test.ps1 index da724fb412..b56f6e0033 100644 --- a/scripts/device-test.ps1 +++ b/scripts/device-test.ps1 @@ -9,7 +9,7 @@ param( ) Set-StrictMode -Version latest -$ErrorActionPreference = "Stop" +$ErrorActionPreference = 'Stop' if (!$Build -and !$Run) { @@ -50,7 +50,7 @@ try dotnet build -f $tfm -c Release test/Sentry.Maui.Device.TestApp if ($LASTEXITCODE -ne 0) { - throw "Failed to build Sentry.Maui.Device.TestApp" + throw 'Failed to build Sentry.Maui.Device.TestApp' } } @@ -59,7 +59,7 @@ try if (!(Get-Command xharness -ErrorAction SilentlyContinue)) { Push-Location ($CI ? $env:RUNNER_TEMP : $IsWindows ? $env:TMP : $IsMacos ? $env:TMPDIR : '/temp') - dotnet tool install Microsoft.DotNet.XHarness.CLI --global --version "1.*-*" ` + dotnet tool install Microsoft.DotNet.XHarness.CLI --global --version '1.*-*' ` --add-source https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json Pop-Location } @@ -70,7 +70,7 @@ try xharness $group test $arguments --output-directory=test_output if ($LASTEXITCODE -ne 0) { - throw "xharness run failed with non-zero exit code" + throw 'xharness run failed with non-zero exit code' } } finally diff --git a/scripts/generate-solution-filters-config.yaml b/scripts/generate-solution-filters-config.yaml index 80900ec608..84ac17e54e 100644 --- a/scripts/generate-solution-filters-config.yaml +++ b/scripts/generate-solution-filters-config.yaml @@ -91,8 +91,10 @@ filterConfigs: - "**/Sentry.Extensions.Logging.csproj" - "**/Sentry.Extensions.Logging.Tests.csproj" - "**/Sentry.OpenTelemetry.csproj" + - "**/Sentry.Serilog.csproj" - "**/Sentry.csproj" - "**/Sentry.Testing.csproj" + - "**/Sentry.Testing.CrashableApp.csproj" - "**/Sentry.Tests.csproj" - outputPath: Sentry.Azure.Functions.slnf diff --git a/src/Sentry/DisabledMetricAggregator.cs b/src/Sentry/DisabledMetricAggregator.cs new file mode 100644 index 0000000000..61304898cf --- /dev/null +++ b/src/Sentry/DisabledMetricAggregator.cs @@ -0,0 +1,50 @@ +namespace Sentry; + +internal class DisabledMetricAggregator : IMetricAggregator +{ + public void Increment(string key, double value = 1.0, MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, int stackLevel = 1) + { + // No Op + } + + public void Gauge(string key, double value = 1.0, MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, int stackLevel = 1) + { + // No Op + } + + public void Distribution(string key, double value = 1.0, MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, int stackLevel = 1) + { + // No Op + } + + public void Set(string key, int value, MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, int stackLevel = 1) + { + // No Op + } + + public void Timing(string key, double value, MeasurementUnit.Duration unit = MeasurementUnit.Duration.Second, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, int stackLevel = 1) + { + // No Op + } + + public Task FlushAsync(bool force = true, CancellationToken cancellationToken = default) + { + // No Op + return Task.CompletedTask; + } + + public void Dispose() + { + // No Op + } +} diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index c41ea7121e..3ce135f59a 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -174,6 +174,11 @@ public void CaptureSession(SessionUpdate sessionUpdate) /// public Task FlushAsync(TimeSpan timeout) => Task.CompletedTask; + /// + /// Disabled Metrics Aggregator (all methods are no-op). + /// + public IMetricAggregator Metrics { get; } = new DisabledMetricAggregator(); + /// /// No-Op. /// diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs index 6f7cbaa4d1..072a6af077 100644 --- a/src/Sentry/Extensibility/HubAdapter.cs +++ b/src/Sentry/Extensibility/HubAdapter.cs @@ -271,6 +271,10 @@ public void CaptureSession(SessionUpdate sessionUpdate) public Task FlushAsync(TimeSpan timeout) => SentrySdk.FlushAsync(timeout); + /// + public IMetricAggregator Metrics + => SentrySdk.Metrics; + /// /// Forwards the call to /// diff --git a/src/Sentry/IMetricAggregator.cs b/src/Sentry/IMetricAggregator.cs new file mode 100644 index 0000000000..04bc946b97 --- /dev/null +++ b/src/Sentry/IMetricAggregator.cs @@ -0,0 +1,107 @@ +namespace Sentry; + +/// +/// Exposes EXPERIMENTAL capability to emit metrics. This API is subject to change without major version bumps so use +/// with caution. We advise disabling in production at the moment. +/// +public interface IMetricAggregator: IDisposable +{ + /// + /// Emits a Counter metric + /// + /// A unique key identifying the metric + /// The value to be added + /// An optional + /// Optional Tags to associate with the metric + /// + /// The time when the metric was emitted. Defaults to the time at which the metric is emitted, if no value is provided. + /// + /// Optional number of stacks levels to ignore when determining the code location + void Increment(string key, + double value = 1.0, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1); + + /// + /// Emits a Gauge metric + /// + /// A unique key identifying the metric + /// The value to be added + /// An optional + /// Optional Tags to associate with the metric + /// + /// The time when the metric was emitted. Defaults to the time at which the metric is emitted, if no value is provided. + /// + /// Optional number of stacks levels to ignore when determining the code location + void Gauge(string key, + double value = 1.0, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1); + + /// + /// Emits a Distribution metric + /// + /// A unique key identifying the metric + /// The value to be added + /// An optional + /// Optional Tags to associate with the metric + /// + /// The time when the metric was emitted. Defaults to the time at which the metric is emitted, if no value is provided. + /// + /// Optional number of stacks levels to ignore when determining the code location + void Distribution(string key, + double value = 1.0, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1); + + /// + /// Emits a Set metric + /// + /// A unique key identifying the metric + /// The value to be added + /// An optional + /// Optional Tags to associate with the metric + /// + /// The time when the metric was emitted. Defaults to the time at which the metric is emitted, if no value is provided. + /// + /// Optional number of stacks levels to ignore when determining the code location + void Set(string key, + int value, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1); + + /// + /// Emits a distribution with the time it takes to run a given code block. + /// + /// A unique key identifying the metric + /// The value to be added + /// + /// An optional . Defaults to + /// + /// Optional Tags to associate with the metric + /// The time when the metric was emitted + /// Optional number of stacks levels to ignore when determining the code location + void Timing(string key, + double value, + MeasurementUnit.Duration unit = MeasurementUnit.Duration.Second, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1); + + /// + /// Flushes any flushable metrics and/or code locations. + /// If is true then the cutoff is ignored and all metrics are flushed. + /// + /// Forces all buckets to be flushed, ignoring the cutoff + /// A + /// False if a shutdown is requested during flush, true otherwise + Task FlushAsync(bool force = true, CancellationToken cancellationToken = default); +} diff --git a/src/Sentry/ISentryClient.cs b/src/Sentry/ISentryClient.cs index 0b1d193225..237f3e04ff 100644 --- a/src/Sentry/ISentryClient.cs +++ b/src/Sentry/ISentryClient.cs @@ -68,4 +68,9 @@ public interface ISentryClient /// The amount of time allowed for flushing. /// A task to await for the flush operation. Task FlushAsync(TimeSpan timeout); + + /// + /// + /// + IMetricAggregator Metrics { get; } } diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 9a04e418f3..9dd0f78250 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -20,6 +20,9 @@ internal class Hub : IHub, IDisposable internal IInternalScopeManager ScopeManager { get; } + /// + public IMetricAggregator Metrics { get; } + private int _isEnabled = 1; public bool IsEnabled => _isEnabled == 1; @@ -56,6 +59,8 @@ internal Hub( PushScope(); } + Metrics = _ownedClient.Metrics; + foreach (var integration in options.Integrations) { options.LogDebug("Registering integration: '{0}'.", integration.GetType().Name); @@ -520,7 +525,16 @@ public void Dispose() return; } - _ownedClient.Flush(_options.ShutdownTimeout); + try + { + _ownedClient.Metrics.FlushAsync().ContinueWith(_ => + _ownedClient.FlushAsync(_options.ShutdownTimeout).Wait() + ).ConfigureAwait(false).GetAwaiter().GetResult(); + } + catch (Exception e) + { + _options.LogError(e, "Failed to wait on disposing tasks to flush."); + } //Dont dispose of ScopeManager since we want dangling transactions to still be able to access tags. #if __IOS__ diff --git a/src/Sentry/MeasurementUnit.cs b/src/Sentry/MeasurementUnit.cs index 2fe9776f09..b572e335bd 100644 --- a/src/Sentry/MeasurementUnit.cs +++ b/src/Sentry/MeasurementUnit.cs @@ -93,4 +93,4 @@ internal static MeasurementUnit Parse(string? name) /// Returns true if the operands are not equal. /// public static bool operator !=(MeasurementUnit left, MeasurementUnit right) => !left.Equals(right); -} \ No newline at end of file +} diff --git a/src/Sentry/MetricAggregator.cs b/src/Sentry/MetricAggregator.cs new file mode 100644 index 0000000000..82de196960 --- /dev/null +++ b/src/Sentry/MetricAggregator.cs @@ -0,0 +1,506 @@ +using System; +using Sentry.Extensibility; +using Sentry.Internal; +using Sentry.Internal.Extensions; +using Sentry.Protocol.Metrics; + +namespace Sentry; + +internal class MetricAggregator : IMetricAggregator +{ + private readonly SentryOptions _options; + private readonly Action> _captureMetrics; + private readonly Action _captureCodeLocations; + private readonly TimeSpan _flushInterval; + + private readonly SemaphoreSlim _codeLocationLock = new(1,1); + + private readonly CancellationTokenSource _shutdownSource; + private volatile bool _disposed; + + // The key for this dictionary is the Timestamp for the bucket, rounded down to the nearest RollupInSeconds... so it + // aggregates all of the metrics data for a particular time period. The Value is a dictionary for the metrics, + // each of which has a key that uniquely identifies it within the time period + internal ConcurrentDictionary> Buckets => _buckets.Value; + + private readonly Lazy>> _buckets + = new(() => new ConcurrentDictionary>()); + + private long _lastClearedStaleLocations = DateTimeOffset.UtcNow.GetDayBucketKey(); + private readonly ConcurrentDictionary> _seenLocations = new(); + private Dictionary> _pendingLocations = new(); + + private readonly Task _loopTask; + + /// + /// MetricAggregator constructor. + /// + /// The + /// The callback to be called to transmit aggregated metrics + /// The callback to be called to transmit new code locations + /// A + /// + /// A boolean value indicating whether the Loop to flush metrics should run, for testing only. + /// + /// An optional flushInterval, for testing only + internal MetricAggregator(SentryOptions options, Action> captureMetrics, + Action captureCodeLocations, CancellationTokenSource? shutdownSource = null, + bool disableLoopTask = false, TimeSpan? flushInterval = null) + { + _options = options; + _captureMetrics = captureMetrics; + _captureCodeLocations = captureCodeLocations; + _shutdownSource = shutdownSource ?? new CancellationTokenSource(); + _flushInterval = flushInterval ?? TimeSpan.FromSeconds(5); + + if (disableLoopTask) + { + // We can stop the loop from running during testing + _options.LogDebug("LoopTask disabled."); + _loopTask = Task.CompletedTask; + } + else + { + options.LogDebug("Starting MetricsAggregator."); + _loopTask = Task.Run(RunLoopAsync); + } + } + + internal static string GetMetricBucketKey(MetricType type, string metricKey, MeasurementUnit unit, + IDictionary? tags) + { + var typePrefix = type.ToStatsdType(); + var serializedTags = GetTagsKey(tags); + + return $"{typePrefix}_{metricKey}_{unit}_{serializedTags}"; + } + + internal static string GetTagsKey(IDictionary? tags) + { + if (tags == null || tags.Count == 0) + { + return string.Empty; + } + + const char pairDelimiter = ','; // Delimiter between key-value pairs + const char keyValueDelimiter = '='; // Delimiter between key and value + const char escapeChar = '\\'; + + var builder = new StringBuilder(); + + foreach (var tag in tags) + { + // Escape delimiters in key and value + var key = EscapeString(tag.Key, pairDelimiter, keyValueDelimiter, escapeChar); + var value = EscapeString(tag.Value, pairDelimiter, keyValueDelimiter, escapeChar); + + if (builder.Length > 0) + { + builder.Append(pairDelimiter); + } + + builder.Append(key).Append(keyValueDelimiter).Append(value); + } + + return builder.ToString(); + + static string EscapeString(string input, params char[] charsToEscape) + { + var escapedString = new StringBuilder(input.Length); + + foreach (var ch in input) + { + if (charsToEscape.Contains(ch)) + { + escapedString.Append(escapeChar); // Prefix with escape character + } + escapedString.Append(ch); + } + + return escapedString.ToString(); + } + } + + /// + public void Increment(string key, + double value = 1.0, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1) => Emit(MetricType.Counter, key, value, unit, tags, timestamp, stackLevel + 1); + + /// + public void Gauge(string key, + double value = 1.0, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1) => Emit(MetricType.Gauge, key, value, unit, tags, timestamp, stackLevel + 1); + + /// + public void Distribution(string key, + double value = 1.0, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1) => Emit(MetricType.Distribution, key, value, unit, tags, timestamp, stackLevel + 1); + + /// + public void Set(string key, + int value, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1) => Emit(MetricType.Set, key, value, unit, tags, timestamp, stackLevel + 1); + + /// + public void Timing(string key, + double value, + MeasurementUnit.Duration unit = MeasurementUnit.Duration.Second, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1) => Emit(MetricType.Distribution, key, value, unit, tags, timestamp, stackLevel + 1); + + private void Emit( + MetricType type, + string key, + double value = 1.0, + MeasurementUnit? unit = null, + IDictionary? tags = null, + DateTimeOffset? timestamp = null, + int stackLevel = 1 + ) + { + timestamp ??= DateTimeOffset.UtcNow; + unit ??= MeasurementUnit.None; + + Func addValuesFactory = type switch + { + MetricType.Counter => _ => new CounterMetric(key, value, unit.Value, tags, timestamp), + MetricType.Gauge => _ => new GaugeMetric(key, value, unit.Value, tags, timestamp), + MetricType.Distribution => _ => new DistributionMetric(key, value, unit.Value, tags, timestamp), + MetricType.Set => _ => new SetMetric(key, (int)value, unit.Value, tags, timestamp), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown MetricType") + }; + + var timeBucket = Buckets.GetOrAdd( + timestamp.Value.GetTimeBucketKey(), + _ => new ConcurrentDictionary() + ); + + timeBucket.AddOrUpdate( + GetMetricBucketKey(type, key, unit.Value, tags), + addValuesFactory, + (_, metric) => + { + // This prevents multiple threads from trying to mutate the metric at the same time. The only other + // operations performed against metrics are adding one to the bucket (guaranteed to be atomic due to + // the use of a ConcurrentDictionary for the timeBucket) and removing buckets entirely. + // + // With a very small flushShift (e.g. 0.0) it might be possible for a metric to be emitted to a bucket + // that was removed after a flush, in which case that metric.Add(value) would never make it to Sentry. + // We've never seen this happen in unit testing (where we always set the flushShift to 0.0) so this + // remains only a theoretical possibility of data loss (not confirmed). If this becomes a real problem + // and we need to guarantee delivery of every metric.Add, we'll need to build a more complex mechanism + // to coordinate flushing with emission. + lock(metric) + { + metric.Add(value); + } + return metric; + }); + + if (_options.ExperimentalMetrics is { EnableCodeLocations: true }) + { + RecordCodeLocation(type, key, unit.Value, stackLevel + 1, timestamp.Value); + } + } + + internal void RecordCodeLocation( + MetricType type, + string key, + MeasurementUnit unit, + int stackLevel, + DateTimeOffset timestamp + ) + { + var startOfDay = timestamp.GetDayBucketKey(); + var metaKey = new MetricResourceIdentifier(type, key, unit); + var seenToday = _seenLocations.GetOrAdd(startOfDay,_ => []); + + _codeLocationLock.Wait(); + try + { + // Group metadata by day to make flushing more efficient. + if (!seenToday.Add(metaKey)) + { + // If we've seen the location, we don't want to create a stack trace etc. again. It could be a different + // location with the same metaKey but the alternative would be to generate the stack trace every time a + // metric is recorded, which would impact performance too much. + return; + } + + if (GetCodeLocation(stackLevel + 1) is not { } location) + { + return; + } + + if (!_pendingLocations.TryGetValue(startOfDay, out var todaysLocations)) + { + todaysLocations = new Dictionary(); + _pendingLocations[startOfDay] = todaysLocations; + } + + todaysLocations[metaKey] = location; + } + finally + { + _codeLocationLock.Release(); + } + } + + internal SentryStackFrame? GetCodeLocation(int stackLevel) + { + var stackTrace = new StackTrace(true); + var frames = DebugStackTrace.Create(_options, stackTrace, false).Frames; + return (frames.Count >= stackLevel) + ? frames[^(stackLevel + 1)] + : null; + } + + private async Task RunLoopAsync() + { + _options.LogDebug("MetricsAggregator Started."); + + using var shutdownTimeout = new CancellationTokenSource(); + var shutdownRequested = false; + + try + { + while (!shutdownTimeout.IsCancellationRequested) + { + // If the cancellation was signaled, run until the end of the queue or shutdownTimeout + try + { + await Task.Delay(_flushInterval, _shutdownSource.Token).ConfigureAwait(false); + } + // Cancellation requested and no timeout allowed, so exit even if there are more items + catch (OperationCanceledException) when (_options.ShutdownTimeout == TimeSpan.Zero) + { + _options.LogDebug("Exiting immediately due to 0 shutdown timeout."); + + await shutdownTimeout.CancelAsync().ConfigureAwait(false); + + return; + } + // Cancellation requested, scheduled shutdown + catch (OperationCanceledException) + { + _options.LogDebug( + "Shutdown scheduled. Stopping by: {0}.", + _options.ShutdownTimeout); + + shutdownTimeout.CancelAfterSafe(_options.ShutdownTimeout); + + shutdownRequested = true; + } + + await FlushAsync(shutdownRequested, shutdownTimeout.Token).ConfigureAwait(false); + + if (shutdownRequested) + { + return; + } + } + } + catch (Exception e) + { + _options.LogFatal(e, "Exception in the Metric Aggregator."); + throw; + } + } + + private readonly SemaphoreSlim _flushLock = new(1, 1); + + /// + public async Task FlushAsync(bool force = true, CancellationToken cancellationToken = default) + { + try + { + await _flushLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + foreach (var key in GetFlushableBuckets(force)) + { + cancellationToken.ThrowIfCancellationRequested(); + + _options.LogDebug("Flushing metrics for bucket {0}", key); + if (!Buckets.TryRemove(key, out var bucket)) + { + continue; + } + + _captureMetrics(bucket.Values); + _options.LogDebug("Metric flushed for bucket {0}", key); + } + + foreach (var (timestamp, locations) in FlushableLocations()) + { + cancellationToken.ThrowIfCancellationRequested(); + + _options.LogDebug("Flushing code locations: ", timestamp); + var codeLocations = new CodeLocations(timestamp, locations); + _captureCodeLocations(codeLocations); + _options.LogDebug("Code locations flushed: ", timestamp); + } + + ClearStaleLocations(); + } + catch (OperationCanceledException) + { + _options.LogInfo("Shutdown token triggered. Exiting metric aggregator."); + } + catch (Exception exception) + { + _options.LogError(exception, "Error processing metrics."); + } + finally + { + _flushLock.Release(); + } + } + + /// + /// Returns the keys for any buckets that are ready to be flushed (i.e. are for periods before the cutoff) + /// + /// Forces all buckets to be flushed, ignoring the cutoff + /// + /// An enumerable containing the keys for any buckets that are ready to be flushed + /// + internal IEnumerable GetFlushableBuckets(bool force = false) + { + if (!_buckets.IsValueCreated) + { + yield break; + } + + if (force) + { + // Return all the buckets in this case + foreach (var key in Buckets.Keys) + { + yield return key; + } + } + else + { + var cutoff = MetricHelper.GetCutoff(); + foreach (var key in Buckets.Keys) + { + var bucketTime = DateTimeOffset.FromUnixTimeSeconds(key); + if (bucketTime < cutoff) + { + yield return key; + } + } + } + } + + private Dictionary> FlushableLocations() + { + _codeLocationLock.Wait(); + try + { + var result = _pendingLocations; + _pendingLocations = new Dictionary>(); + return result; + } + finally + { + _codeLocationLock.Release(); + } + } + + /// + /// Clear out stale seen locations once a day + /// + private void ClearStaleLocations() + { + var now = DateTimeOffset.UtcNow; + var today = now.GetDayBucketKey(); + if (_lastClearedStaleLocations == today) + { + return; + } + // Allow 60 seconds for all code locations to be sent at the transition from one day to the next + const int staleGraceInMinutes = 1; + if (now.Minute < staleGraceInMinutes) + { + return; + } + + foreach (var dailyValues in _seenLocations.Keys.ToArray()) + { + if (dailyValues < today) + { + _seenLocations.TryRemove(dailyValues, out _); + } + } + _lastClearedStaleLocations = today; + } + + /// + public async ValueTask DisposeAsync() + { + _options.LogDebug("Disposing MetricAggregator."); + + if (_disposed) + { + _options.LogDebug("Already disposed MetricAggregator."); + return; + } + + _disposed = true; + + try + { + // Request the LoopTask stop. + await _shutdownSource.CancelAsync().ConfigureAwait(false); + + // Now wait for the Loop to stop. + // NOTE: While non-intuitive, do not pass a timeout or cancellation token here. We are waiting for + // the _continuation_ of the method, not its _execution_. If we stop waiting prematurely, this may cause + // unexpected behavior in client applications. + await _loopTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _options.LogDebug("Stopping the Metric Aggregator due to a cancellation."); + } + catch (Exception exception) + { + _options.LogError(exception, "Async Disposing the Metric Aggregator threw an exception."); + } + finally + { + _flushLock.Dispose(); + _shutdownSource.Dispose(); + _loopTask.Dispose(); + } + } + + public void Dispose() + { + try + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + // Ignore + } + catch (Exception exception) + { + _options.LogError(exception, "Disposing the Metric Aggregator threw an exception."); + } + } +} diff --git a/src/Sentry/MetricHelper.cs b/src/Sentry/MetricHelper.cs new file mode 100644 index 0000000000..8042bce07f --- /dev/null +++ b/src/Sentry/MetricHelper.cs @@ -0,0 +1,56 @@ +using Sentry.Internal; + +namespace Sentry; + +internal static partial class MetricHelper +{ + private static readonly RandomValuesFactory Random = new SynchronizedRandomValuesFactory(); + + private const int RollupInSeconds = 10; + +#if NET6_0_OR_GREATER + private static readonly DateTimeOffset UnixEpoch = DateTimeOffset.UnixEpoch; +#else + private static readonly DateTimeOffset UnixEpoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); +#endif + + internal static long GetDayBucketKey(this DateTimeOffset timestamp) + { + var utc = timestamp.ToUniversalTime(); + var dayOnly = new DateTimeOffset(utc.Year, utc.Month, utc.Day, 0, 0, 0, 0, TimeSpan.Zero); + return (long)(dayOnly - UnixEpoch).TotalSeconds; + } + + internal static long GetTimeBucketKey(this DateTimeOffset timestamp) + { + var seconds = (long)(timestamp.ToUniversalTime() - UnixEpoch).TotalSeconds; + return (seconds / RollupInSeconds) * RollupInSeconds; + } + + /// + /// The aggregator shifts it's flushing by up to an entire rollup window to avoid multiple clients trampling on end + /// of a 10 second window as all the buckets are anchored to multiples of ROLLUP seconds. We randomize this number + /// once per aggregator boot to achieve some level of offsetting across a fleet of deployed SDKs. Relay itself will + /// also apply independent jittering. + /// + /// Internal for testing + internal static double FlushShift = Random.NextInt(0, 1000) * RollupInSeconds; + internal static DateTimeOffset GetCutoff() => DateTimeOffset.UtcNow + .Subtract(TimeSpan.FromSeconds(RollupInSeconds)) + .Subtract(TimeSpan.FromMilliseconds(FlushShift)); + +#if NET7_0_OR_GREATER + [GeneratedRegex(@"[^a-zA-Z0-9_/.-]+", RegexOptions.Compiled)] + private static partial Regex InvalidKeyCharacters(); + internal static string SanitizeKey(string input) => InvalidKeyCharacters().Replace(input, "_"); + + [GeneratedRegex(@"[^\w\d_:/@\.\{\}\[\]$-]+", RegexOptions.Compiled)] + private static partial Regex InvalidValueCharacters(); + internal static string SanitizeValue(string input) => InvalidValueCharacters().Replace(input, "_"); +#else + private static readonly Regex InvalidKeyCharacters = new(@"[^a-zA-Z0-9_/.-]+", RegexOptions.Compiled); + internal static string SanitizeKey(string input) => InvalidKeyCharacters.Replace(input, "_"); + private static readonly Regex InvalidValueCharacters = new(@"[^\w\d_:/@\.\{\}\[\]$-]+", RegexOptions.Compiled); + internal static string SanitizeValue(string input) => InvalidValueCharacters.Replace(input, "_"); +#endif +} diff --git a/src/Sentry/Platforms/Cocoa/CocoaProfiler.cs b/src/Sentry/Platforms/Cocoa/CocoaProfiler.cs index 9f73e53703..7192918ea0 100644 --- a/src/Sentry/Platforms/Cocoa/CocoaProfiler.cs +++ b/src/Sentry/Platforms/Cocoa/CocoaProfiler.cs @@ -36,8 +36,7 @@ public void Finish() public ISerializable? Collect(Transaction transaction) { - // TODO change return type of CocoaSDKs CollectProfileBetween to NSMutableDictionary - var payload = SentryCocoaHybridSdk.CollectProfileBetween(_startTimeNs, _endTimeNs, _cocoaTraceId)?.MutableCopy() as NSMutableDictionary; + var payload = SentryCocoaHybridSdk.CollectProfileBetween(_startTimeNs, _endTimeNs, _cocoaTraceId); if (payload is null) { _options.LogWarning("Trace {0} collected profile payload is null", _traceId); diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 1b21c5c57f..35aac2f4d7 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -2,6 +2,7 @@ using Sentry.Infrastructure; using Sentry.Internal; using Sentry.Internal.Extensions; +using Sentry.Protocol.Metrics; namespace Sentry.Protocol.Envelopes; @@ -325,6 +326,35 @@ public static Envelope FromTransaction(Transaction transaction) return new Envelope(eventId, header, items); } + /// + /// Creates an envelope that contains one or more + /// + internal static Envelope FromCodeLocations(CodeLocations codeLocations) + { + var header = DefaultHeader; + + var items = new List(1); + items.Add(EnvelopeItem.FromCodeLocations(codeLocations)); + + return new Envelope(header, items); + } + + /// + /// Creates an envelope that contains one or more Metrics + /// + internal static Envelope FromMetrics(IEnumerable metrics) + { + var header = DefaultHeader; + + List items = new(); + foreach (var metric in metrics) + { + items.Add(EnvelopeItem.FromMetric(metric)); + } + + return new Envelope(header, items); + } + /// /// Creates an envelope that contains a session update. /// diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index a2557cf8ef..fc02034331 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -1,6 +1,7 @@ using Sentry.Extensibility; using Sentry.Internal; using Sentry.Internal.Extensions; +using Sentry.Protocol.Metrics; namespace Sentry.Protocol.Envelopes; @@ -18,6 +19,8 @@ public sealed class EnvelopeItem : ISerializable, IDisposable private const string TypeValueAttachment = "attachment"; private const string TypeValueClientReport = "client_report"; private const string TypeValueProfile = "profile"; + private const string TypeValueMetric = "statsd"; + private const string TypeValueCodeLocations = "metric_meta"; private const string LengthKey = "length"; private const string FileNameKey = "filename"; @@ -221,6 +224,34 @@ public static EnvelopeItem FromTransaction(Transaction transaction) return new EnvelopeItem(header, new JsonSerializable(transaction)); } + /// + /// Creates an from one or more . + /// + internal static EnvelopeItem FromCodeLocations(CodeLocations codeLocations) + { + var header = new Dictionary(1, StringComparer.Ordinal) + { + [TypeKey] = TypeValueCodeLocations + }; + + // Note that metrics are serialized using statsd encoding (not JSON) + return new EnvelopeItem(header, new JsonSerializable(codeLocations)); + } + + /// + /// Creates an from . + /// + internal static EnvelopeItem FromMetric(Metric metric) + { + var header = new Dictionary(1, StringComparer.Ordinal) + { + [TypeKey] = TypeValueMetric + }; + + // Note that metrics are serialized using statsd encoding (not JSON) + return new EnvelopeItem(header, metric); + } + /// /// Creates an from . /// diff --git a/src/Sentry/Protocol/Metrics/CodeLocations.cs b/src/Sentry/Protocol/Metrics/CodeLocations.cs new file mode 100644 index 0000000000..a8bf10e2f7 --- /dev/null +++ b/src/Sentry/Protocol/Metrics/CodeLocations.cs @@ -0,0 +1,41 @@ +using Sentry.Extensibility; +using Sentry.Internal.Extensions; + +namespace Sentry.Protocol.Metrics; + +internal class CodeLocations(long timestamp, IReadOnlyDictionary locations) + : IJsonSerializable +{ + /// + /// Uniquely identifies a code location using the number of seconds since the UnixEpoch, as measured at the start + /// of the day when the code location was recorded. + /// + public long Timestamp => timestamp; + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + writer.WriteNumber("timestamp", Timestamp); + + var mapping = locations.ToDictionary( + kvp => kvp.Key.ToString(), + kvp => + { + var loc = kvp.Value; + loc.IsCodeLocation = true; + return loc; + }); + + writer.WritePropertyName("mapping"); + writer.WriteStartObject(); + foreach (var (mri, loc) in mapping) + { + // The protocol supports multiple locations per MRI but currently the Sentry Relay will discard all but the + // first, so even though we only capture a single location we send it through as an array. + // See: https://discord.com/channels/621778831602221064/1184350202774163556/1185010167369170974 + writer.WriteArray(mri, new[]{loc}, logger); + } + writer.WriteEndObject(); + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/Protocol/Metrics/CounterMetric.cs b/src/Sentry/Protocol/Metrics/CounterMetric.cs new file mode 100644 index 0000000000..81778fc640 --- /dev/null +++ b/src/Sentry/Protocol/Metrics/CounterMetric.cs @@ -0,0 +1,33 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol.Metrics; + +/// +/// Counters track a value that can only be incremented. +/// +internal class CounterMetric : Metric +{ + public CounterMetric() + { + Value = 0; + } + + public CounterMetric(string key, double value, MeasurementUnit? unit = null, IDictionary? tags = null, DateTimeOffset? timestamp = null) + : base(key, unit, tags, timestamp) + { + Value = value; + } + + public double Value { get; private set; } + + public override void Add(double value) => Value += value; + + protected override void WriteValues(Utf8JsonWriter writer, IDiagnosticLogger? logger) => + writer.WriteNumber("value", Value); + + protected override IEnumerable SerializedStatsdValues() + { + yield return Value; + } +} diff --git a/src/Sentry/Protocol/Metrics/DistributionMetric.cs b/src/Sentry/Protocol/Metrics/DistributionMetric.cs new file mode 100644 index 0000000000..7d6a14ffe3 --- /dev/null +++ b/src/Sentry/Protocol/Metrics/DistributionMetric.cs @@ -0,0 +1,34 @@ +using Sentry.Extensibility; +using Sentry.Internal.Extensions; + +namespace Sentry.Protocol.Metrics; + +/// +/// Distributions track a list of values over time in on which you can perform aggregations like max, min, avg. +/// +internal class DistributionMetric : Metric +{ + private readonly List _value; + + public DistributionMetric() + { + _value = new List(); + } + + public DistributionMetric(string key, double value, MeasurementUnit? unit = null, + IDictionary? tags = null, DateTimeOffset? timestamp = null) + : base(key, unit, tags, timestamp) + { + _value = new List() { value }; + } + + public IReadOnlyList Value => _value; + + public override void Add(double value) => _value.Add(value); + + protected override void WriteValues(Utf8JsonWriter writer, IDiagnosticLogger? logger) => + writer.WriteArrayIfNotEmpty("value", _value, logger); + + protected override IEnumerable SerializedStatsdValues() + => _value.Cast(); +} diff --git a/src/Sentry/Protocol/Metrics/GaugeMetric.cs b/src/Sentry/Protocol/Metrics/GaugeMetric.cs new file mode 100644 index 0000000000..cefd262038 --- /dev/null +++ b/src/Sentry/Protocol/Metrics/GaugeMetric.cs @@ -0,0 +1,66 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol.Metrics; + +/// +/// Gauges track a value that can go up and down. +/// +internal class GaugeMetric : Metric +{ + public GaugeMetric() + { + Value = 0; + First = 0; + Min = 0; + Max = 0; + Sum = 0; + Count = 0; + } + + public GaugeMetric(string key, double value, MeasurementUnit? unit = null, IDictionary? tags = null, + DateTimeOffset? timestamp = null) + : base(key, unit, tags, timestamp) + { + Value = value; + First = value; + Min = value; + Max = value; + Sum = value; + Count = 1; + } + + public double Value { get; private set; } + public double First { get; } + public double Min { get; private set; } + public double Max { get; private set; } + public double Sum { get; private set; } + public double Count { get; private set; } + + public override void Add(double value) + { + Value = value; + Min = Math.Min(Min, value); + Max = Math.Max(Max, value); + Sum += value; + Count++; + } + + protected override void WriteValues(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteNumber("value", Value); + writer.WriteNumber("first", First); + writer.WriteNumber("min", Min); + writer.WriteNumber("max", Max); + writer.WriteNumber("sum", Sum); + writer.WriteNumber("count", Count); + } + + protected override IEnumerable SerializedStatsdValues() + { + yield return Value; + yield return Min; + yield return Max; + yield return Sum; + yield return Count; + } +} diff --git a/src/Sentry/Protocol/Metrics/Metric.cs b/src/Sentry/Protocol/Metrics/Metric.cs new file mode 100644 index 0000000000..7a9a5ca737 --- /dev/null +++ b/src/Sentry/Protocol/Metrics/Metric.cs @@ -0,0 +1,128 @@ +using Sentry.Extensibility; +using Sentry.Internal.Extensions; +using ISentrySerializable = Sentry.Protocol.Envelopes.ISerializable; + +namespace Sentry.Protocol.Metrics; + +internal abstract class Metric : IJsonSerializable, ISentrySerializable +{ + protected Metric() : this(string.Empty) + { + } + + protected Metric(string key, MeasurementUnit? unit = null, IDictionary? tags = null, DateTimeOffset? timestamp = null) + { + Key = key; + Unit = unit; + _tags = tags; + Timestamp = timestamp ?? DateTimeOffset.UtcNow; + } + + public SentryId EventId { get; private set; } = SentryId.Create(); + + public string Key { get; private set; } + + public DateTimeOffset Timestamp { get; private set; } + + public MeasurementUnit? Unit { get; private set; } + + private IDictionary? _tags; + + public IDictionary Tags + { + get + { + _tags ??= new Dictionary(); + return _tags; + } + } + + public abstract void Add(double value); + + protected abstract void WriteValues(Utf8JsonWriter writer, IDiagnosticLogger? logger); + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + writer.WriteString("type", GetType().Name); + writer.WriteSerializable("event_id", EventId, logger); + writer.WriteString("name", Key); + writer.WriteString("timestamp", Timestamp); + if (Unit.HasValue) + { + writer.WriteStringIfNotWhiteSpace("unit", Unit.ToString()); + } + writer.WriteStringDictionaryIfNotEmpty("tags", (IEnumerable>?)_tags); + WriteValues(writer, logger); + writer.WriteEndObject(); + } + + protected abstract IEnumerable SerializedStatsdValues(); + + public async Task SerializeAsync(Stream stream, IDiagnosticLogger? logger, CancellationToken cancellationToken = default) + { + /* + * We're serializing using the statsd format here: https://github.com/b/statsd_spec + */ + var metricName = MetricHelper.SanitizeKey(Key); + await Write($"{metricName}@").ConfigureAwait(false); + var unit = Unit ?? MeasurementUnit.None; +// We don't need ConfigureAwait(false) here as ConfigureAwait on metricName above avoids capturing the ExecutionContext. +#pragma warning disable CA2007 + await Write(unit.ToString()); + + foreach (var value in SerializedStatsdValues()) + { + await Write($":{value.ToString(CultureInfo.InvariantCulture)}"); + } + + await Write($"|{StatsdType}"); + + if (_tags is { Count: > 0 } tags) + { + await Write("|#"); + var first = true; + foreach (var (key, value) in tags) + { + var tagKey = MetricHelper.SanitizeKey(key); + if (string.IsNullOrWhiteSpace(tagKey)) + { + continue; + } + if (first) + { + first = false; + } + else + { + await Write(","); + } + await Write($"{key}:SanitizeValue(value)"); + } + } + + await Write($"|T{Timestamp.GetTimeBucketKey().ToString(CultureInfo.InvariantCulture)}\n"); + return; +#pragma warning restore CA2007 + + async Task Write(string content) + { + await stream.WriteAsync(Encoding.UTF8.GetBytes(content), cancellationToken).ConfigureAwait(false); + } + } + + public void Serialize(Stream stream, IDiagnosticLogger? logger) + { + SerializeAsync(stream, logger).GetAwaiter().GetResult(); + } + + private string StatsdType => + this switch + { + CounterMetric _ => "c", + GaugeMetric _ => "g", + DistributionMetric _ => "d", + SetMetric _ => "s", + _ => throw new ArgumentOutOfRangeException(GetType().Name, "Unable to infer statsd type") + }; +} diff --git a/src/Sentry/Protocol/Metrics/MetricResourceIdentifier.cs b/src/Sentry/Protocol/Metrics/MetricResourceIdentifier.cs new file mode 100644 index 0000000000..3cd3a3906e --- /dev/null +++ b/src/Sentry/Protocol/Metrics/MetricResourceIdentifier.cs @@ -0,0 +1,7 @@ +namespace Sentry.Protocol.Metrics; + +internal record struct MetricResourceIdentifier(MetricType MetricType, string Key, MeasurementUnit Unit) +{ + public override string ToString() + => $"{MetricType.ToStatsdType()}:{MetricHelper.SanitizeKey(Key)}@{Unit}"; +} diff --git a/src/Sentry/Protocol/Metrics/MetricType.cs b/src/Sentry/Protocol/Metrics/MetricType.cs new file mode 100644 index 0000000000..c323b914dd --- /dev/null +++ b/src/Sentry/Protocol/Metrics/MetricType.cs @@ -0,0 +1,16 @@ +namespace Sentry.Protocol.Metrics; + +internal enum MetricType : byte { Counter, Gauge, Distribution, Set } + +internal static class MetricTypeExtensions +{ + internal static string ToStatsdType(this MetricType type) => + type switch + { + MetricType.Counter => "c", + MetricType.Gauge => "g", + MetricType.Distribution => "d", + MetricType.Set => "s", + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) + }; +} diff --git a/src/Sentry/Protocol/Metrics/SetMetric.cs b/src/Sentry/Protocol/Metrics/SetMetric.cs new file mode 100644 index 0000000000..ac102bdf37 --- /dev/null +++ b/src/Sentry/Protocol/Metrics/SetMetric.cs @@ -0,0 +1,34 @@ +using Sentry.Extensibility; +using Sentry.Internal.Extensions; + +namespace Sentry.Protocol.Metrics; + +/// +/// Sets track a set of values on which you can perform aggregations such as count_unique. +/// +internal class SetMetric : Metric +{ + private readonly HashSet _value; + + public SetMetric() + { + _value = new HashSet(); + } + + public SetMetric(string key, int value, MeasurementUnit? unit = null, IDictionary? tags = null, + DateTimeOffset? timestamp = null) + : base(key, unit, tags, timestamp) + { + _value = new HashSet() { value }; + } + + public IReadOnlyCollection Value => _value; + + public override void Add(double value) => _value.Add((int)value); + + protected override void WriteValues(Utf8JsonWriter writer, IDiagnosticLogger? logger) => + writer.WriteArrayIfNotEmpty("value", _value, logger); + + protected override IEnumerable SerializedStatsdValues() + => _value.Select(v => (IConvertible)v); +} diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 9c04e4467e..3f2f4b9126 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -110,13 +110,13 @@ <_OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) - - - - - - - + + + + + + + diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 520e1954a0..c5035edfc0 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -1,6 +1,7 @@ using Sentry.Extensibility; using Sentry.Internal; using Sentry.Protocol.Envelopes; +using Sentry.Protocol.Metrics; namespace Sentry; @@ -21,6 +22,12 @@ public class SentryClient : ISentryClient, IDisposable private readonly Enricher _enricher; internal IBackgroundWorker Worker { get; } + + /// + /// + /// + public IMetricAggregator Metrics { get; } + internal SentryOptions Options => _options; /// @@ -70,6 +77,15 @@ internal SentryClient( options.LogDebug("Worker of type {0} was provided via Options.", worker.GetType().Name); Worker = worker; } + + if (options.ExperimentalMetrics is not null) + { + Metrics = new MetricAggregator(options, CaptureMetrics, CaptureCodeLocations); + } + else + { + Metrics = new DisabledMetricAggregator(); + } } /// @@ -225,6 +241,24 @@ public void CaptureTransaction(Transaction transaction, Scope? scope, Hint? hint return transaction; } + /// + /// Captures one or more metrics to be sent to Sentry. + /// + internal void CaptureMetrics(IEnumerable metrics) + { + _options.LogDebug("Capturing metrics."); + CaptureEnvelope(Envelope.FromMetrics(metrics)); + } + + /// + /// Captures one or more to be sent to Sentry. + /// + internal void CaptureCodeLocations(CodeLocations codeLocations) + { + _options.LogDebug("Capturing code locations for period: {0}", codeLocations.Timestamp); + CaptureEnvelope(Envelope.FromCodeLocations(codeLocations)); + } + /// public void CaptureSession(SessionUpdate sessionUpdate) { @@ -431,12 +465,20 @@ private bool CaptureEnvelope(Envelope envelope) /// /// Disposes this client /// - /// public void Dispose() { _options.LogDebug("Flushing SentryClient."); - // Worker should empty it's queue until SentryOptions.ShutdownTimeout - Worker.FlushAsync(_options.ShutdownTimeout).GetAwaiter().GetResult(); + try + { + Metrics.FlushAsync().ContinueWith(_ => + // Worker should empty it's queue until SentryOptions.ShutdownTimeout + Worker.FlushAsync(_options.ShutdownTimeout) + ).ConfigureAwait(false).GetAwaiter().GetResult(); + } + catch + { + _options.LogDebug("Failed to wait on metrics/worker to flush"); + } } } diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index 6c409b91d8..53af99daa8 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1118,10 +1118,22 @@ public bool JsonPreserveReferences public Func? AssemblyReader { get; set; } /// - /// The Spotlight URL. Defaults to http://localhost:8969/stream + /// + /// Settings for the EXPERIMENTAL metrics feature. This feature is preview only and subject to change without a + /// major version bump. Currently it's recommended for noodling only - DON'T USE IN PRODUCTION! + /// + /// + /// By default the ExperimentalMetrics Options is null, which means the feature is disabled. If you want to enable + /// Experimental metrics, you must set this property to a non-null value. + /// /// + public ExperimentalMetricsOptions? ExperimentalMetrics { get; set; } + + /// + /// The Spotlight URL. Defaults to http://localhost:8969/stream /// /// + /// public string SpotlightUrl { get; set; } = "http://localhost:8969/stream"; /// @@ -1302,3 +1314,16 @@ internal enum DefaultIntegrations #endif } } + +/// +/// Settings for the experimental Metrics feature. This feature is preview only and will very likely change in the future +/// without a major version bump... so use at your own risk. +/// +public class ExperimentalMetricsOptions +{ + /// + /// Determines the sample rate for metrics. 0.0 means no metrics will be sent (metrics disabled). 1.0 implies all + /// metrics will be sent. + /// + public bool EnableCodeLocations { get; set; } = true; +} diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 3151dbf0a1..43a8ec5f8c 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -618,6 +618,10 @@ public static TransactionContext ContinueTrace( string? operation = null) => CurrentHub.ContinueTrace(traceHeader, baggageHeader, name, operation); + /// + public static IMetricAggregator Metrics + => CurrentHub.Metrics; + /// [DebuggerStepThrough] public static void StartSession() diff --git a/src/Sentry/SentryStackFrame.cs b/src/Sentry/SentryStackFrame.cs index f845fcd457..5cc26814a2 100644 --- a/src/Sentry/SentryStackFrame.cs +++ b/src/Sentry/SentryStackFrame.cs @@ -17,6 +17,13 @@ public sealed class SentryStackFrame : IJsonSerializable internal List? InternalFramesOmitted { get; private set; } + /// + /// When serializing a stack frame as part of the Code Location metadata for Metrics, we need to include an + /// additional "type" property in the serialized payload. This flag indicates whether the stack frame is for + /// a code location or not. + /// + internal bool IsCodeLocation { get; set; } = false; + /// /// The relative file path to the call. /// @@ -139,6 +146,12 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) { writer.WriteStartObject(); + if (IsCodeLocation) + { + // See https://develop.sentry.dev/sdk/metrics/#meta-data + writer.WriteString("type", "location"); + } + writer.WriteStringArrayIfNotEmpty("pre_context", InternalPreContext); writer.WriteStringArrayIfNotEmpty("post_context", InternalPostContext); writer.WriteStringDictionaryIfNotEmpty("vars", InternalVars!); diff --git a/src/Sentry/Timing.cs b/src/Sentry/Timing.cs new file mode 100644 index 0000000000..bdd206d2b1 --- /dev/null +++ b/src/Sentry/Timing.cs @@ -0,0 +1,100 @@ +using Sentry.Extensibility; +using Sentry.Protocol.Metrics; + +namespace Sentry; + +/// +/// Measures the time it takes to run a given code block and emits this as a metric. The class is +/// designed to be used in a using statement. +/// +/// +/// using (var timing = new Timing("my-operation")) +/// { +/// ... +/// } +/// +public class Timing: IDisposable +{ + private readonly IHub _hub; + private readonly string _key; + private readonly MeasurementUnit.Duration _unit; + private readonly IDictionary? _tags; + private readonly Stopwatch _stopwatch = new(); + private readonly ISpan _span; + private readonly DateTime _startTime = DateTime.UtcNow; + + /// + /// Creates a new instance. + /// + public Timing(string key, MeasurementUnit.Duration unit = MeasurementUnit.Duration.Second, + IDictionary? tags = null) + : this(SentrySdk.CurrentHub, key, unit, tags, stackLevel: 2 /* one for each constructor */) + { + } + + /// + /// Creates a new instance. + /// + public Timing(IHub hub, string key, MeasurementUnit.Duration unit = MeasurementUnit.Duration.Second, + IDictionary? tags = null) + : this(hub, key, unit, tags, stackLevel: 2 /* one for each constructor */) + { + } + + internal Timing(IHub hub, string key, MeasurementUnit.Duration unit, IDictionary? tags, + int stackLevel) + { + _hub = hub; + _key = key; + _unit = unit; + _tags = tags; + _stopwatch.Start(); + + ITransactionTracer? currentTransaction = null; + hub.ConfigureScope(s => currentTransaction = s.Transaction); + _span = currentTransaction is {} transaction + ? transaction.StartChild("metric.timing", key) + : hub.StartTransaction("metric.timing", key); + if (tags is not null) + { + _span.SetTags(tags); + } + + // Report code locations here for better accuracy + if (hub.Metrics is MetricAggregator metrics) + { + metrics.RecordCodeLocation(MetricType.Distribution, key, unit, stackLevel + 1, _startTime); + } + } + + /// + public void Dispose() + { + _stopwatch.Stop(); + + try + { + var value = _unit switch + { + MeasurementUnit.Duration.Week => _stopwatch.Elapsed.TotalDays / 7, + MeasurementUnit.Duration.Day => _stopwatch.Elapsed.TotalDays, + MeasurementUnit.Duration.Hour => _stopwatch.Elapsed.TotalHours, + MeasurementUnit.Duration.Minute => _stopwatch.Elapsed.TotalMinutes, + MeasurementUnit.Duration.Second => _stopwatch.Elapsed.TotalSeconds, + MeasurementUnit.Duration.Millisecond => _stopwatch.Elapsed.TotalMilliseconds, + MeasurementUnit.Duration.Microsecond => _stopwatch.Elapsed.TotalMilliseconds * 1000, + MeasurementUnit.Duration.Nanosecond => _stopwatch.Elapsed.TotalMilliseconds * 1000000, + _ => throw new ArgumentOutOfRangeException(nameof(_unit), _unit, null) + }; + _hub.Metrics.Timing(_key, value, _unit, _tags, _startTime); + } + catch (Exception e) + { + _hub.GetSentryOptions()?.LogError(e, "Error capturing timing '{0}'", _key); + } + finally + { + _span?.Finish(); + } + } +} diff --git a/src/Sentry/TransactionTracer.cs b/src/Sentry/TransactionTracer.cs index e5ce135801..8202933529 100644 --- a/src/Sentry/TransactionTracer.cs +++ b/src/Sentry/TransactionTracer.cs @@ -370,15 +370,6 @@ public void Finish() EndTimestamp ??= _stopwatch.CurrentDateTimeOffset; _options?.LogDebug("Finished Transaction {0}.", SpanId); - foreach (var span in _spans) - { - if (!span.IsFinished) - { - _options?.LogDebug("Deadline exceeded for Transaction {0} -> Span {1}.", SpanId, span.SpanId); - span.Finish(SpanStatus.DeadlineExceeded); - } - } - // Clear the transaction from the scope _hub.ConfigureScope(scope => scope.ResetTransaction(this)); diff --git a/test/Sentry.AspNetCore.Tests/BindableSentryAspNetCoreOptionsTests.cs b/test/Sentry.AspNetCore.Tests/BindableSentryAspNetCoreOptionsTests.cs index 0df315c7b2..b832f0607d 100644 --- a/test/Sentry.AspNetCore.Tests/BindableSentryAspNetCoreOptionsTests.cs +++ b/test/Sentry.AspNetCore.Tests/BindableSentryAspNetCoreOptionsTests.cs @@ -5,6 +5,10 @@ namespace Sentry.AspNetCore.Tests; public class BindableSentryAspNetCoreOptionsTests: BindableTests { + public BindableSentryAspNetCoreOptionsTests() : base(nameof(SentryAspNetCoreOptions.ExperimentalMetrics)) + { + } + [Fact] public void BindableProperties_MatchOptionsProperties() { diff --git a/test/Sentry.Azure.Functions.Worker.Tests/BindableSentryAzureFunctionsOptionsTests.cs b/test/Sentry.Azure.Functions.Worker.Tests/BindableSentryAzureFunctionsOptionsTests.cs index c225daa771..5d21a7d3a4 100644 --- a/test/Sentry.Azure.Functions.Worker.Tests/BindableSentryAzureFunctionsOptionsTests.cs +++ b/test/Sentry.Azure.Functions.Worker.Tests/BindableSentryAzureFunctionsOptionsTests.cs @@ -5,6 +5,10 @@ namespace Sentry.Azure.Functions.Worker.Tests; public class BindableSentryAzureFunctionsOptionsTests: BindableTests { + public BindableSentryAzureFunctionsOptionsTests() : base(nameof(SentryAzureFunctionsOptions.ExperimentalMetrics)) + { + } + [Fact] public void BindableProperties_MatchOptionsProperties() { diff --git a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsTests.cs b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsTests.cs index 772473b267..26ba0fb740 100644 --- a/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsTests.cs +++ b/test/Sentry.Extensions.Logging.Tests/SentryLoggingOptionsTests.cs @@ -5,6 +5,10 @@ namespace Sentry.Extensions.Logging.Tests; public class SentryLoggingOptionsTests : BindableTests { + public SentryLoggingOptionsTests() : base(nameof(SentryLoggingOptions.ExperimentalMetrics)) + { + } + [Fact] public void BindableProperties_MatchOptionsProperties() { diff --git a/test/Sentry.Maui.Tests/BindableSentryMauiOptionsTests.cs b/test/Sentry.Maui.Tests/BindableSentryMauiOptionsTests.cs index a94421c9e8..821f529746 100644 --- a/test/Sentry.Maui.Tests/BindableSentryMauiOptionsTests.cs +++ b/test/Sentry.Maui.Tests/BindableSentryMauiOptionsTests.cs @@ -5,6 +5,10 @@ namespace Sentry.Maui.Tests; public class BindableSentryMauiOptionsTests: BindableTests { + public BindableSentryMauiOptionsTests() : base(nameof(SentryMauiOptions.ExperimentalMetrics)) + { + } + [Fact] public void BindableProperties_MatchOptionsProperties() { diff --git a/test/Sentry.Profiling.Tests/SamplingTransactionProfilerTests.cs b/test/Sentry.Profiling.Tests/SamplingTransactionProfilerTests.cs index 18558e6d84..6764077874 100644 --- a/test/Sentry.Profiling.Tests/SamplingTransactionProfilerTests.cs +++ b/test/Sentry.Profiling.Tests/SamplingTransactionProfilerTests.cs @@ -124,10 +124,9 @@ public void Profiler_WithZeroStartupTimeout_CapturesAfterStartingAsynchronously( CaptureAndValidate(factory); } - // https://github.com/getsentry/sentry-dotnet/issues/3006 - // [SkippableTheory] - // [InlineData(0)] - // [InlineData(10)] + [SkippableTheory] + [InlineData(0)] + [InlineData(10)] private void Profiler_SingleProfile_Works(int startTimeoutSeconds) { using var factory = new SamplingTransactionProfilerFactory(_testSentryOptions, TimeSpan.FromSeconds(startTimeoutSeconds)); @@ -179,7 +178,7 @@ public async Task Profiler_AfterTimeout_Stops() } } - [Theory] + [SkippableTheory] [InlineData(true)] [InlineData(false)] public void ProfilerIntegration_FullRoundtrip_Works(bool offlineCaching) @@ -284,8 +283,7 @@ async Task VerifyAsync(HttpRequestMessage message) } } - // https://github.com/getsentry/sentry-dotnet/issues/3009 - // [Fact] + [SkippableFact] private async Task Profiler_ThrowingOnSessionStartup_DoesntBreakSentryInit() { SampleProfilerSession.ThrowOnNextStartupForTests = true; @@ -319,6 +317,7 @@ async Task VerifyAsync(HttpRequestMessage message) try { SampleProfilerSession.ThrowOnNextStartupForTests.Should().BeTrue(); + options.TransactionProfilerFactory.Should().BeNull(); using var hub = (SentrySdk.InitHub(options) as Hub)!; SampleProfilerSession.ThrowOnNextStartupForTests.Should().BeFalse(); options.TransactionProfilerFactory.Should().BeNull(); @@ -344,7 +343,7 @@ async Task VerifyAsync(HttpRequestMessage message) } } - [Fact] + [SkippableFact] public void ProfilerIntegration_WithProfilingDisabled_LeavesFactoryNull() { var options = new SentryOptions @@ -358,7 +357,7 @@ public void ProfilerIntegration_WithProfilingDisabled_LeavesFactoryNull() Assert.Null(hub.Options.TransactionProfilerFactory); } - [Fact] + [SkippableFact] public void ProfilerIntegration_WithTracingDisabled_LeavesFactoryNull() { var options = new SentryOptions @@ -372,7 +371,7 @@ public void ProfilerIntegration_WithTracingDisabled_LeavesFactoryNull() Assert.Null(hub.Options.TransactionProfilerFactory); } - [Fact] + [SkippableFact] public void ProfilerIntegration_WithProfilingEnabled_SetsFactory() { var options = new SentryOptions @@ -386,7 +385,7 @@ public void ProfilerIntegration_WithProfilingEnabled_SetsFactory() Assert.NotNull(hub.Options.TransactionProfilerFactory); } - [Fact] + [SkippableFact] public void Downsampler_ShouldSample_Works() { var sut = new Downsampler(); diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt index a1b0578bb6..f4b2f68503 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt @@ -126,6 +126,11 @@ namespace Sentry public static void SetFingerprint(this Sentry.IEventLike eventLike, System.Collections.Generic.IEnumerable fingerprint) { } public static void SetFingerprint(this Sentry.IEventLike eventLike, params string[] fingerprint) { } } + public class ExperimentalMetricsOptions + { + public ExperimentalMetricsOptions() { } + public bool EnableCodeLocations { get; set; } + } public class FileAttachmentContent : Sentry.IAttachmentContent { public FileAttachmentContent(string filePath) { } @@ -237,6 +242,15 @@ namespace Sentry { void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger); } + public interface IMetricAggregator : System.IDisposable + { + void Distribution(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + System.Threading.Tasks.Task FlushAsync(bool force = true, System.Threading.CancellationToken cancellationToken = default); + void Gauge(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Increment(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Set(string key, int value, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Timing(string key, double value, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + } public interface IScopeObserver { void AddBreadcrumb(Sentry.Breadcrumb breadcrumb); @@ -248,6 +262,7 @@ namespace Sentry public interface ISentryClient { bool IsEnabled { get; } + Sentry.IMetricAggregator Metrics { get; } Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.Scope? scope = null, Sentry.Hint? hint = null); void CaptureSession(Sentry.SessionUpdate sessionUpdate); void CaptureTransaction(Sentry.Transaction transaction); @@ -475,6 +490,7 @@ namespace Sentry { public SentryClient(Sentry.SentryOptions options) { } public bool IsEnabled { get; } + public Sentry.IMetricAggregator Metrics { get; } public Sentry.SentryId CaptureEvent(Sentry.SentryEvent? @event, Sentry.Scope? scope = null, Sentry.Hint? hint = null) { } public void CaptureSession(Sentry.SessionUpdate sessionUpdate) { } public void CaptureTransaction(Sentry.Transaction transaction) { } @@ -621,6 +637,7 @@ namespace Sentry public bool EnableSpotlight { get; set; } public bool? EnableTracing { get; set; } public string? Environment { get; set; } + public Sentry.ExperimentalMetricsOptions? ExperimentalMetrics { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -705,6 +722,7 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } + public static Sentry.IMetricAggregator Metrics { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.Hint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -1001,6 +1019,12 @@ namespace Sentry public override string ToString() { } public static Sentry.SubstringOrRegexPattern op_Implicit(string substringOrRegexPattern) { } } + public class Timing : System.IDisposable + { + public Timing(string key, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null) { } + public Timing(Sentry.IHub hub, string key, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null) { } + public void Dispose() { } + } public class Transaction : Sentry.IEventLike, Sentry.IHasExtra, Sentry.IHasTags, Sentry.IJsonSerializable, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext { public Transaction(Sentry.ITransactionTracer tracer) { } @@ -1184,6 +1208,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.IMetricAggregator Metrics { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope) { } @@ -1221,6 +1246,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.IMetricAggregator Metrics { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt index a1b0578bb6..f4b2f68503 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt @@ -126,6 +126,11 @@ namespace Sentry public static void SetFingerprint(this Sentry.IEventLike eventLike, System.Collections.Generic.IEnumerable fingerprint) { } public static void SetFingerprint(this Sentry.IEventLike eventLike, params string[] fingerprint) { } } + public class ExperimentalMetricsOptions + { + public ExperimentalMetricsOptions() { } + public bool EnableCodeLocations { get; set; } + } public class FileAttachmentContent : Sentry.IAttachmentContent { public FileAttachmentContent(string filePath) { } @@ -237,6 +242,15 @@ namespace Sentry { void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger); } + public interface IMetricAggregator : System.IDisposable + { + void Distribution(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + System.Threading.Tasks.Task FlushAsync(bool force = true, System.Threading.CancellationToken cancellationToken = default); + void Gauge(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Increment(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Set(string key, int value, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Timing(string key, double value, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + } public interface IScopeObserver { void AddBreadcrumb(Sentry.Breadcrumb breadcrumb); @@ -248,6 +262,7 @@ namespace Sentry public interface ISentryClient { bool IsEnabled { get; } + Sentry.IMetricAggregator Metrics { get; } Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.Scope? scope = null, Sentry.Hint? hint = null); void CaptureSession(Sentry.SessionUpdate sessionUpdate); void CaptureTransaction(Sentry.Transaction transaction); @@ -475,6 +490,7 @@ namespace Sentry { public SentryClient(Sentry.SentryOptions options) { } public bool IsEnabled { get; } + public Sentry.IMetricAggregator Metrics { get; } public Sentry.SentryId CaptureEvent(Sentry.SentryEvent? @event, Sentry.Scope? scope = null, Sentry.Hint? hint = null) { } public void CaptureSession(Sentry.SessionUpdate sessionUpdate) { } public void CaptureTransaction(Sentry.Transaction transaction) { } @@ -621,6 +637,7 @@ namespace Sentry public bool EnableSpotlight { get; set; } public bool? EnableTracing { get; set; } public string? Environment { get; set; } + public Sentry.ExperimentalMetricsOptions? ExperimentalMetrics { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -705,6 +722,7 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } + public static Sentry.IMetricAggregator Metrics { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.Hint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -1001,6 +1019,12 @@ namespace Sentry public override string ToString() { } public static Sentry.SubstringOrRegexPattern op_Implicit(string substringOrRegexPattern) { } } + public class Timing : System.IDisposable + { + public Timing(string key, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null) { } + public Timing(Sentry.IHub hub, string key, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null) { } + public void Dispose() { } + } public class Transaction : Sentry.IEventLike, Sentry.IHasExtra, Sentry.IHasTags, Sentry.IJsonSerializable, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext { public Transaction(Sentry.ITransactionTracer tracer) { } @@ -1184,6 +1208,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.IMetricAggregator Metrics { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope) { } @@ -1221,6 +1246,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.IMetricAggregator Metrics { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 65343dc5e2..92e27b6397 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -127,6 +127,11 @@ namespace Sentry public static void SetFingerprint(this Sentry.IEventLike eventLike, System.Collections.Generic.IEnumerable fingerprint) { } public static void SetFingerprint(this Sentry.IEventLike eventLike, params string[] fingerprint) { } } + public class ExperimentalMetricsOptions + { + public ExperimentalMetricsOptions() { } + public bool EnableCodeLocations { get; set; } + } public class FileAttachmentContent : Sentry.IAttachmentContent { public FileAttachmentContent(string filePath) { } @@ -238,6 +243,15 @@ namespace Sentry { void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger); } + public interface IMetricAggregator : System.IDisposable + { + void Distribution(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + System.Threading.Tasks.Task FlushAsync(bool force = true, System.Threading.CancellationToken cancellationToken = default); + void Gauge(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Increment(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Set(string key, int value, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Timing(string key, double value, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + } public interface IScopeObserver { void AddBreadcrumb(Sentry.Breadcrumb breadcrumb); @@ -249,6 +263,7 @@ namespace Sentry public interface ISentryClient { bool IsEnabled { get; } + Sentry.IMetricAggregator Metrics { get; } Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.Scope? scope = null, Sentry.Hint? hint = null); void CaptureSession(Sentry.SessionUpdate sessionUpdate); void CaptureTransaction(Sentry.Transaction transaction); @@ -476,6 +491,7 @@ namespace Sentry { public SentryClient(Sentry.SentryOptions options) { } public bool IsEnabled { get; } + public Sentry.IMetricAggregator Metrics { get; } public Sentry.SentryId CaptureEvent(Sentry.SentryEvent? @event, Sentry.Scope? scope = null, Sentry.Hint? hint = null) { } public void CaptureSession(Sentry.SessionUpdate sessionUpdate) { } public void CaptureTransaction(Sentry.Transaction transaction) { } @@ -622,6 +638,7 @@ namespace Sentry public bool EnableSpotlight { get; set; } public bool? EnableTracing { get; set; } public string? Environment { get; set; } + public Sentry.ExperimentalMetricsOptions? ExperimentalMetrics { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -706,6 +723,7 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } + public static Sentry.IMetricAggregator Metrics { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.Hint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -1002,6 +1020,12 @@ namespace Sentry public override string ToString() { } public static Sentry.SubstringOrRegexPattern op_Implicit(string substringOrRegexPattern) { } } + public class Timing : System.IDisposable + { + public Timing(string key, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null) { } + public Timing(Sentry.IHub hub, string key, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null) { } + public void Dispose() { } + } public class Transaction : Sentry.IEventLike, Sentry.IHasExtra, Sentry.IHasTags, Sentry.IJsonSerializable, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext { public Transaction(Sentry.ITransactionTracer tracer) { } @@ -1185,6 +1209,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.IMetricAggregator Metrics { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope) { } @@ -1222,6 +1247,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.IMetricAggregator Metrics { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index f2e898c8d3..9e7a64a190 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -125,6 +125,11 @@ namespace Sentry public static void SetFingerprint(this Sentry.IEventLike eventLike, System.Collections.Generic.IEnumerable fingerprint) { } public static void SetFingerprint(this Sentry.IEventLike eventLike, params string[] fingerprint) { } } + public class ExperimentalMetricsOptions + { + public ExperimentalMetricsOptions() { } + public bool EnableCodeLocations { get; set; } + } public class FileAttachmentContent : Sentry.IAttachmentContent { public FileAttachmentContent(string filePath) { } @@ -236,6 +241,15 @@ namespace Sentry { void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger); } + public interface IMetricAggregator : System.IDisposable + { + void Distribution(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + System.Threading.Tasks.Task FlushAsync(bool force = true, System.Threading.CancellationToken cancellationToken = default); + void Gauge(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Increment(string key, double value = 1, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Set(string key, int value, Sentry.MeasurementUnit? unit = default, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + void Timing(string key, double value, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null, System.DateTimeOffset? timestamp = default, int stackLevel = 1); + } public interface IScopeObserver { void AddBreadcrumb(Sentry.Breadcrumb breadcrumb); @@ -247,6 +261,7 @@ namespace Sentry public interface ISentryClient { bool IsEnabled { get; } + Sentry.IMetricAggregator Metrics { get; } Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.Scope? scope = null, Sentry.Hint? hint = null); void CaptureSession(Sentry.SessionUpdate sessionUpdate); void CaptureTransaction(Sentry.Transaction transaction); @@ -474,6 +489,7 @@ namespace Sentry { public SentryClient(Sentry.SentryOptions options) { } public bool IsEnabled { get; } + public Sentry.IMetricAggregator Metrics { get; } public Sentry.SentryId CaptureEvent(Sentry.SentryEvent? @event, Sentry.Scope? scope = null, Sentry.Hint? hint = null) { } public void CaptureSession(Sentry.SessionUpdate sessionUpdate) { } public void CaptureTransaction(Sentry.Transaction transaction) { } @@ -619,6 +635,7 @@ namespace Sentry public bool EnableSpotlight { get; set; } public bool? EnableTracing { get; set; } public string? Environment { get; set; } + public Sentry.ExperimentalMetricsOptions? ExperimentalMetrics { get; set; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -702,6 +719,7 @@ namespace Sentry { public static bool IsEnabled { get; } public static Sentry.SentryId LastEventId { get; } + public static Sentry.IMetricAggregator Metrics { get; } public static void AddBreadcrumb(Sentry.Breadcrumb breadcrumb, Sentry.Hint? hint = null) { } public static void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public static void AddBreadcrumb(Sentry.Infrastructure.ISystemClock? clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } @@ -998,6 +1016,12 @@ namespace Sentry public override string ToString() { } public static Sentry.SubstringOrRegexPattern op_Implicit(string substringOrRegexPattern) { } } + public class Timing : System.IDisposable + { + public Timing(string key, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null) { } + public Timing(Sentry.IHub hub, string key, Sentry.MeasurementUnit.Duration unit = 3, System.Collections.Generic.IDictionary? tags = null) { } + public void Dispose() { } + } public class Transaction : Sentry.IEventLike, Sentry.IHasExtra, Sentry.IHasTags, Sentry.IJsonSerializable, Sentry.ISpanData, Sentry.ITransactionContext, Sentry.ITransactionData, Sentry.Protocol.ITraceContext { public Transaction(Sentry.ITransactionTracer tracer) { } @@ -1181,6 +1205,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.DisabledHub Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.IMetricAggregator Metrics { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope) { } @@ -1218,6 +1243,7 @@ namespace Sentry.Extensibility public static readonly Sentry.Extensibility.HubAdapter Instance; public bool IsEnabled { get; } public Sentry.SentryId LastEventId { get; } + public Sentry.IMetricAggregator Metrics { get; } public void AddBreadcrumb(string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void AddBreadcrumb(Sentry.Infrastructure.ISystemClock clock, string message, string? category = null, string? type = null, System.Collections.Generic.IDictionary? data = null, Sentry.BreadcrumbLevel level = 0) { } public void BindClient(Sentry.ISentryClient client) { } diff --git a/test/Sentry.Tests/BindableSentryOptionsTests.cs b/test/Sentry.Tests/BindableSentryOptionsTests.cs index 50f39e954a..977f8570d8 100644 --- a/test/Sentry.Tests/BindableSentryOptionsTests.cs +++ b/test/Sentry.Tests/BindableSentryOptionsTests.cs @@ -5,6 +5,10 @@ namespace Sentry.Tests; public class BindableSentryOptionsTests : BindableTests { + public BindableSentryOptionsTests() : base(nameof(SentryOptions.ExperimentalMetrics)) + { + } + [Fact] public void BindableProperties_MatchOptionsProperties() { diff --git a/test/Sentry.Tests/MetricAggregatorTests.cs b/test/Sentry.Tests/MetricAggregatorTests.cs new file mode 100644 index 0000000000..04d0daeeee --- /dev/null +++ b/test/Sentry.Tests/MetricAggregatorTests.cs @@ -0,0 +1,268 @@ +using Sentry.Protocol.Metrics; + +namespace Sentry.Tests; + +public class MetricAggregatorTests +{ + class Fixture + { + public SentryOptions Options { get; set; } = new(); + public Action> CaptureMetrics { get; set; } = (_ => { }); + public Action CaptureCodeLocations { get; set; } = (_ => { }); + public bool DisableFlushLoop { get; set; } = true; + public TimeSpan? FlushInterval { get; set; } + public MetricAggregator GetSut() + => new(Options, CaptureMetrics, CaptureCodeLocations, disableLoopTask: DisableFlushLoop, flushInterval: FlushInterval); + } + + // private readonly Fixture _fixture = new(); + private static readonly Fixture _fixture = new(); + + [Fact] + public void GetMetricBucketKey_GeneratesExpectedKey() + { + // Arrange + var type = MetricType.Counter; + var metricKey = "quibbles"; + var unit = MeasurementUnit.None; + var tags = new Dictionary { ["tag1"] = "value1" }; + + // Act + var result = MetricAggregator.GetMetricBucketKey(type, metricKey, unit, tags); + + // Assert + result.Should().Be("c_quibbles_none_tag1=value1"); + } + + [Fact] + public void Increment_AggregatesMetrics() + { + // Arrange + var metricType = MetricType.Counter; + var key = "counter_key"; + var unit = MeasurementUnit.None; + var tags = new Dictionary { ["tag1"] = "value1" }; + var sut = _fixture.GetSut(); + + // Act + DateTimeOffset firstTime = new(1970, 1, 1, 0, 0, 31, 0, TimeSpan.Zero); + sut.Increment(key, 3, unit, tags, firstTime); + + DateTimeOffset secondTime = new(1970, 1, 1, 0, 0, 38, 0, TimeSpan.Zero); + sut.Increment(key, 5, unit, tags, secondTime); + + DateTimeOffset thirdTime = new(1970, 1, 1, 0, 0, 40, 0, TimeSpan.Zero); + sut.Increment(key, 13, unit, tags, thirdTime); + + // Assert + var bucket1 = sut.Buckets[firstTime.GetTimeBucketKey()]; + var data1 = (CounterMetric)bucket1[MetricAggregator.GetMetricBucketKey(metricType, key, unit, tags)]; + data1.Value.Should().Be(8); // First two emits are in the same bucket + + var bucket2 = sut.Buckets[thirdTime.GetTimeBucketKey()]; + var data2 = (CounterMetric)bucket2[MetricAggregator.GetMetricBucketKey(metricType, key, unit, tags)]; + data2.Value.Should().Be(13); // First two emits are in the same bucket + } + + [Fact] + public void Gauge_AggregatesMetrics() + { + // Arrange + var metricType = MetricType.Gauge; + var key = "gauge_key"; + var unit = MeasurementUnit.None; + var tags = new Dictionary { ["tag1"] = "value1" }; + var sut = _fixture.GetSut(); + + // Act + DateTimeOffset time1 = new(1970, 1, 1, 0, 0, 31, 0, TimeSpan.Zero); + sut.Gauge(key, 3, unit, tags, time1); + + DateTimeOffset time2 = new(1970, 1, 1, 0, 0, 38, 0, TimeSpan.Zero); + sut.Gauge(key, 5, unit, tags, time2); + + DateTimeOffset time3 = new(1970, 1, 1, 0, 0, 40, 0, TimeSpan.Zero); + sut.Gauge(key, 13, unit, tags, time3); + + // Assert + var bucket1 = sut.Buckets[time1.GetTimeBucketKey()]; + var data1 = (GaugeMetric)bucket1[MetricAggregator.GetMetricBucketKey(metricType, key, unit, tags)]; + data1.Value.Should().Be(5); + data1.First.Should().Be(3); + data1.Min.Should().Be(3); + data1.Max.Should().Be(5); + data1.Sum.Should().Be(8); + data1.Count.Should().Be(2); + + var bucket2 = sut.Buckets[time3.GetTimeBucketKey()]; + var data2 = (GaugeMetric)bucket2[MetricAggregator.GetMetricBucketKey(metricType, key, unit, tags)]; + data2.Value.Should().Be(13); + data2.First.Should().Be(13); + data2.Min.Should().Be(13); + data2.Max.Should().Be(13); + data2.Sum.Should().Be(13); + data2.Count.Should().Be(1); + } + + [Fact] + public void Distribution_AggregatesMetrics() + { + // Arrange + var metricType = MetricType.Distribution; + var key = "distribution_key"; + var unit = MeasurementUnit.None; + var tags = new Dictionary { ["tag1"] = "value1" }; + var sut = _fixture.GetSut(); + + // Act + DateTimeOffset time1 = new(1970, 1, 1, 0, 0, 31, 0, TimeSpan.Zero); + sut.Distribution(key, 3, unit, tags, time1); + + DateTimeOffset time2 = new(1970, 1, 1, 0, 0, 38, 0, TimeSpan.Zero); + sut.Distribution(key, 5, unit, tags, time2); + + DateTimeOffset time3 = new(1970, 1, 1, 0, 0, 40, 0, TimeSpan.Zero); + sut.Distribution(key, 13, unit, tags, time3); + + // Assert + var bucket1 = sut.Buckets[time1.GetTimeBucketKey()]; + var data1 = (DistributionMetric)bucket1[MetricAggregator.GetMetricBucketKey(metricType, key, unit, tags)]; + data1.Value.Should().BeEquivalentTo(new[] {3, 5}); + + var bucket2 = sut.Buckets[time3.GetTimeBucketKey()]; + var data2 = (DistributionMetric)bucket2[MetricAggregator.GetMetricBucketKey(metricType, key, unit, tags)]; + data2.Value.Should().BeEquivalentTo(new[] {13}); + } + + [Fact] + public void Set_AggregatesMetrics() + { + // Arrange + var metricType = MetricType.Set; + var key = "set_key"; + var unit = MeasurementUnit.None; + var tags = new Dictionary { ["tag1"] = "value1" }; + var sut = _fixture.GetSut(); + + // Act + DateTimeOffset time1 = new(1970, 1, 1, 0, 0, 31, 0, TimeSpan.Zero); + sut.Set(key, 3, unit, tags, time1); + + DateTimeOffset time2 = new(1970, 1, 1, 0, 0, 38, 0, TimeSpan.Zero); + sut.Set(key, 5, unit, tags, time2); + + DateTimeOffset time3 = new(1970, 1, 1, 0, 0, 40, 0, TimeSpan.Zero); + sut.Set(key, 13, unit, tags, time3); + + DateTimeOffset time4 = new(1970, 1, 1, 0, 0, 42, 0, TimeSpan.Zero); + sut.Set(key, 13, unit, tags, time3); + + // Assert + var bucket1 = sut.Buckets[time1.GetTimeBucketKey()]; + var data1 = (SetMetric)bucket1[MetricAggregator.GetMetricBucketKey(metricType, key, unit, tags)]; + data1.Value.Should().BeEquivalentTo(new[] {3, 5}); + + var bucket2 = sut.Buckets[time3.GetTimeBucketKey()]; + var data2 = (SetMetric)bucket2[MetricAggregator.GetMetricBucketKey(metricType, key, unit, tags)]; + data2.Value.Should().BeEquivalentTo(new[] {13}); + } + + [Fact] + public async Task GetFlushableBuckets_IsThreadsafe() + { + // Arrange + const int numThreads = 100; + const int numThreadIterations = 1000; + var sent = 0; + MetricHelper.FlushShift = 0.0; + _fixture.DisableFlushLoop = false; + _fixture.FlushInterval = TimeSpan.FromMilliseconds(100); + _fixture.CaptureMetrics = metrics => + { + foreach (var metric in metrics) + { + Interlocked.Add(ref sent, (int)((CounterMetric)metric).Value); + } + }; + var sut = _fixture.GetSut(); + + // Act... spawn some threads that add loads of metrics + var resetEvent = new ManualResetEvent(false); + var toProcess = numThreads; + for(var i = 0; i < numThreads; i++) + { + new Thread(delegate() + { + for(var i = 0; i < numThreadIterations; i++) + { + sut.Increment("counter"); + } + // If we're the last thread, signal + if (Interlocked.Decrement(ref toProcess) == 0) + { + resetEvent.Set(); + } + }).Start(); + } + + // Wait for workers. + resetEvent.WaitOne(); + await sut.FlushAsync(); + + // Assert + sent.Should().Be(numThreads * numThreadIterations); + } + + [Fact] + public void TestGetCodeLocation() + { + // Arrange + _fixture.Options.StackTraceMode = StackTraceMode.Enhanced; + var sut = _fixture.GetSut(); + + // Act + var result = sut.GetCodeLocation(1); + + // Assert + result.Should().NotBeNull(); + result!.Function.Should().Be($"void {nameof(MetricAggregatorTests)}.{nameof(TestGetCodeLocation)}()"); + } + + [Fact] + public void GetTagsKey_ReturnsEmpty_WhenTagsIsNull() + { + var result = MetricAggregator.GetTagsKey(null); + result.Should().BeEmpty(); + } + + [Fact] + public void GetTagsKey_ReturnsEmpty_WhenTagsIsEmpty() + { + var result = MetricAggregator.GetTagsKey(new Dictionary()); + result.Should().BeEmpty(); + } + + [Fact] + public void GetTagsKey_ReturnsValidString_WhenTagsHasOneEntry() + { + var tags = new Dictionary { { "tag1", "value1" } }; + var result = MetricAggregator.GetTagsKey(tags); + result.Should().Be("tag1=value1"); + } + + [Fact] + public void GetTagsKey_ReturnsCorrectString_WhenTagsHasMultipleEntries() + { + var tags = new Dictionary { { "tag1", "value1" }, { "tag2", "value2" } }; + var result = MetricAggregator.GetTagsKey(tags); + result.Should().Be("tag1=value1,tag2=value2"); + } + + [Fact] + public void GetTagsKey_EscapesCharacters_WhenTagsContainDelimiters() + { + var tags = new Dictionary { { "tag1\\", "value1\\" }, { "tag2,", "value2," }, { "tag3=", "value3=" } }; + var result = MetricAggregator.GetTagsKey(tags); + result.Should().Be(@"tag1\\=value1\\,tag2\,=value2\,,tag3\==value3\="); + } +} diff --git a/test/Sentry.Tests/MetricBucketHelperTests.cs b/test/Sentry.Tests/MetricBucketHelperTests.cs new file mode 100644 index 0000000000..5de929afb4 --- /dev/null +++ b/test/Sentry.Tests/MetricBucketHelperTests.cs @@ -0,0 +1,39 @@ +using Sentry.Protocol.Metrics; + +namespace Sentry.Tests; + +public class MetricBucketHelperTests +{ + [Theory] + [InlineData(30)] + [InlineData(31)] + [InlineData(39)] + public void GetTimeBucketKey_RoundsDownToNearestTenSeconds(int seconds) + { + // Arrange + // Returns the number of seconds that have elapsed since 1970-01-01T00:00:00Z + var timestamp = new DateTimeOffset(1970, 1, 1, 1, 1, seconds, TimeSpan.Zero); + + // Act + var result = timestamp.GetTimeBucketKey(); + + // Assert + result.Should().Be(3690); // (1 hour) + (1 minute) plus (30 seconds) = 3690 + } + + [Theory] + [InlineData(1970, 1, 1, 12, 34, 56, 0)] + [InlineData(1970, 1, 2, 12, 34, 56, 1)] + public void GetDayBucketKey_RoundsStartOfDay(int year, int month, int day, int hour, int minute, int second, int expectedDays) + { + // Arrange + var timestamp = new DateTimeOffset(year, month, day, hour, minute, second, TimeSpan.Zero); + + // Act + var result = timestamp.GetDayBucketKey(); + + // Assert + const int secondsInADay = 60 * 60 * 24; + result.Should().Be(expectedDays * secondsInADay); + } +} diff --git a/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=CounterMetric.verified.txt b/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=CounterMetric.verified.txt new file mode 100644 index 0000000000..07089ed3ee --- /dev/null +++ b/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=CounterMetric.verified.txt @@ -0,0 +1 @@ +my.counter@counters:5|c|#tag1:SanitizeValue(value),tag2:SanitizeValue(value)|T1577836800 diff --git a/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=DistributionMetric.verified.txt b/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=DistributionMetric.verified.txt new file mode 100644 index 0000000000..f6a5cd69e2 --- /dev/null +++ b/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=DistributionMetric.verified.txt @@ -0,0 +1 @@ +my.distribution@distributions:5:7:13|d|#tag1:SanitizeValue(value),tag2:SanitizeValue(value)|T1577836800 diff --git a/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=GaugeMetric.verified.txt b/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=GaugeMetric.verified.txt new file mode 100644 index 0000000000..ed798ecca2 --- /dev/null +++ b/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=GaugeMetric.verified.txt @@ -0,0 +1 @@ +my.gauge@gauges:7:5:7:12:2|g|#tag1:SanitizeValue(value),tag2:SanitizeValue(value)|T1577836800 diff --git a/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=SetMetric.verified.txt b/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=SetMetric.verified.txt new file mode 100644 index 0000000000..d147d1fc26 --- /dev/null +++ b/test/Sentry.Tests/MetricTests.SerializeAsync_WritesMetric_metric=SetMetric.verified.txt @@ -0,0 +1 @@ +my.set@sets:5:7|s|#tag1:SanitizeValue(value),tag2:SanitizeValue(value)|T1577836800 diff --git a/test/Sentry.Tests/MetricTests.Serialize_Counter_statsd.DotNet8_0.verified.txt b/test/Sentry.Tests/MetricTests.Serialize_Counter_statsd.DotNet8_0.verified.txt new file mode 100644 index 0000000000..1f67076aaf --- /dev/null +++ b/test/Sentry.Tests/MetricTests.Serialize_Counter_statsd.DotNet8_0.verified.txt @@ -0,0 +1 @@ +my.counter@widgets:5|c|#tag1:SanitizeValue(value),tag2:SanitizeValue(value)|T1702270140 diff --git a/test/Sentry.Tests/MetricTests.verify.cs b/test/Sentry.Tests/MetricTests.verify.cs new file mode 100644 index 0000000000..7c275137c0 --- /dev/null +++ b/test/Sentry.Tests/MetricTests.verify.cs @@ -0,0 +1,50 @@ +using Sentry.Protocol.Metrics; +using ISentrySerializable = Sentry.Protocol.Envelopes.ISerializable; + +namespace Sentry.Tests; + +[UsesVerify] +public class MetricTests +{ + public static IEnumerable GetMetrics() + { + var timestamp = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var tags = new Dictionary + { + { "tag1", "value1" }, + { "tag2", "value2" } + }; + var counter = new CounterMetric("my.counter", 5, MeasurementUnit.Custom("counters"), tags, timestamp); + yield return new object[] { counter }; + + var set = new SetMetric("my.set", 5, MeasurementUnit.Custom("sets"), tags, timestamp); + set.Add(7); + yield return new object[]{ set }; + + var distribution = new DistributionMetric("my.distribution", 5, MeasurementUnit.Custom("distributions"), tags, timestamp); + distribution.Add(7); + distribution.Add(13); + yield return new object[]{ distribution }; + + var gauge = new GaugeMetric("my.gauge", 5, MeasurementUnit.Custom("gauges"), tags, timestamp); + gauge.Add(7); + yield return new object[]{ gauge }; + } + + [Theory] + [MemberData(nameof(GetMetrics))] + public async Task SerializeAsync_WritesMetric(ISentrySerializable metric) + { + // Arrange + var stream = new MemoryStream(); + + // Act + await metric.SerializeAsync(stream, null); + stream.Position = 0; + using var reader = new StreamReader(stream, Encoding.UTF8); + var statsd = await reader.ReadToEndAsync(); + + // Assert + await Verify(statsd).UseParameters(metric.GetType().Name); + } +} diff --git a/test/Sentry.Tests/Protocol/TransactionTests.cs b/test/Sentry.Tests/Protocol/TransactionTests.cs index 0bfa66d2bc..652ddbd402 100644 --- a/test/Sentry.Tests/Protocol/TransactionTests.cs +++ b/test/Sentry.Tests/Protocol/TransactionTests.cs @@ -380,29 +380,6 @@ public void Finish_RecordsTime() (transaction.EndTimestamp - transaction.StartTimestamp).Should().BeGreaterOrEqualTo(TimeSpan.Zero); } - [Fact] - public void Finish_UnfinishedSpansGetsFinishedWithDeadlineStatus() - { - // Arrange - var transaction = new TransactionTracer(DisabledHub.Instance, "my name", "my op"); - transaction.StartChild("children1"); - transaction.StartChild("children2"); - transaction.StartChild("children3.finished").Finish(SpanStatus.Ok); - transaction.StartChild("children4"); - - // Act - transaction.Finish(); - - // Assert - - Assert.All(transaction.Spans.Where(span => !span.Operation.EndsWith("finished")), span => - { - Assert.True(span.IsFinished); - Assert.Equal(SpanStatus.DeadlineExceeded, span.Status); - }); - Assert.Single(transaction.Spans.Where(span => span.Operation.EndsWith("finished") && span.Status == SpanStatus.Ok)); - } - [Fact] public void Finish_SentryRequestSpansGetIgnored() {