diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18a1c71ce0..331a4c76b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,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/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/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/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/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/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/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 2eb25a995f..caa9916933 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/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.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt
index c7cdbe53d4..908954b917 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 c7cdbe53d4..908954b917 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 01a0a20629..7e9aba3bdd 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 ec83a0ab55..31c458b540 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