diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6e31ab46..396ee4a7a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add _experimental_ support for [Sentry trace-connected Metrics](https://docs.sentry.io/product/explore/metrics/) ([#4834](https://github.com/getsentry/sentry-dotnet/pull/4834)) - Extended `SentryThread` by `Main` to allow indication whether the thread is considered the current main thread ([#4807](https://github.com/getsentry/sentry-dotnet/pull/4807)) ### Fixes diff --git a/Directory.Build.props b/Directory.Build.props index 4d8b78ebbd..17290b7262 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,6 +13,7 @@ $(NoWarn);SENTRY0001 + $(NoWarn);SENTRYTRACECONNECTEDMETRICS $(NoWarn);CS8002 diff --git a/benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs b/benchmarks/Sentry.Benchmarks/BatchProcessorBenchmarks.cs similarity index 72% rename from benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs rename to benchmarks/Sentry.Benchmarks/BatchProcessorBenchmarks.cs index 4a2d1fc981..51e0394ee1 100644 --- a/benchmarks/Sentry.Benchmarks/StructuredLogBatchProcessorBenchmarks.cs +++ b/benchmarks/Sentry.Benchmarks/BatchProcessorBenchmarks.cs @@ -4,10 +4,15 @@ namespace Sentry.Benchmarks; -public class StructuredLogBatchProcessorBenchmarks +/// +/// (formerly "Sentry.Internal.StructuredLogBatchProcessor") was originally developed as Batch Processor for Logs only. +/// When adding support for Trace-connected Metrics, which are quite similar to Logs, it has been made generic to support both. +/// For comparability of results, we still benchmark with , rather than . +/// +public class BatchProcessorBenchmarks { private Hub _hub; - private StructuredLogBatchProcessor _batchProcessor; + private BatchProcessor _batchProcessor; private SentryLog _log; [Params(10, 100)] @@ -29,7 +34,7 @@ public void Setup() var clientReportRecorder = new NullClientReportRecorder(); _hub = new Hub(options, DisabledHub.Instance); - _batchProcessor = new StructuredLogBatchProcessor(_hub, BatchCount, batchInterval, clientReportRecorder, null); + _batchProcessor = new SentryLogBatchProcessor(_hub, BatchCount, batchInterval, clientReportRecorder, null); _log = new SentryLog(DateTimeOffset.Now, SentryId.Empty, SentryLogLevel.Trace, "message"); } diff --git a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.BatchProcessorBenchmarks-report-github.md b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.BatchProcessorBenchmarks-report-github.md new file mode 100644 index 0000000000..d298c9a815 --- /dev/null +++ b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.BatchProcessorBenchmarks-report-github.md @@ -0,0 +1,24 @@ +``` + +BenchmarkDotNet v0.13.12, macOS 26.1 (25B78) [Darwin 25.1.0] +Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 10.0.100 + [Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD + + +``` +| Method | BatchCount | OperationsPerInvoke | Mean | Error | StdDev | Median | Gen0 | Allocated | +|------------------------- |----------- |-------------------- |-------------:|------------:|-------------:|-------------:|-------:|----------:| +| **EnqueueAndFlush** | **10** | **100** | **1,896.9 ns** | **9.94 ns** | **8.81 ns** | **1,894.2 ns** | **0.6104** | **5 KB** | +| EnqueueAndFlush_Parallel | 10 | 100 | 16,520.9 ns | 327.78 ns | 746.51 ns | 16,350.4 ns | 1.1292 | 9.29 KB | +| **EnqueueAndFlush** | **10** | **200** | **4,085.5 ns** | **80.03 ns** | **74.86 ns** | **4,087.1 ns** | **1.2207** | **10 KB** | +| EnqueueAndFlush_Parallel | 10 | 200 | 39,371.8 ns | 776.85 ns | 1,360.59 ns | 38,725.0 ns | 1.6479 | 13.6 KB | +| **EnqueueAndFlush** | **10** | **1000** | **18,829.3 ns** | **182.18 ns** | **142.24 ns** | **18,836.4 ns** | **6.1035** | **50 KB** | +| EnqueueAndFlush_Parallel | 10 | 1000 | 151,934.1 ns | 2,631.83 ns | 3,232.12 ns | 151,495.9 ns | 3.6621 | 31.31 KB | +| **EnqueueAndFlush** | **100** | **100** | **864.9 ns** | **2.16 ns** | **1.68 ns** | **865.0 ns** | **0.1469** | **1.2 KB** | +| EnqueueAndFlush_Parallel | 100 | 100 | 7,414.9 ns | 74.86 ns | 70.02 ns | 7,405.9 ns | 0.5722 | 4.61 KB | +| **EnqueueAndFlush** | **100** | **200** | **1,836.9 ns** | **15.28 ns** | **12.76 ns** | **1,834.9 ns** | **0.2937** | **2.41 KB** | +| EnqueueAndFlush_Parallel | 100 | 200 | 37,119.5 ns | 726.04 ns | 1,252.39 ns | 36,968.9 ns | 0.8545 | 7.27 KB | +| **EnqueueAndFlush** | **100** | **1000** | **8,567.2 ns** | **84.25 ns** | **74.68 ns** | **8,547.4 ns** | **1.4648** | **12.03 KB** | +| EnqueueAndFlush_Parallel | 100 | 1000 | 255,284.5 ns | 5,095.08 ns | 12,593.77 ns | 258,313.9 ns | 1.9531 | 19.02 KB | diff --git a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md b/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md deleted file mode 100644 index 8461170b2c..0000000000 --- a/benchmarks/Sentry.Benchmarks/BenchmarkDotNet.Artifacts/results/Sentry.Benchmarks.StructuredLogBatchProcessorBenchmarks-report-github.md +++ /dev/null @@ -1,24 +0,0 @@ -``` - -BenchmarkDotNet v0.13.12, macOS 15.6 (24G84) [Darwin 24.6.0] -Apple M3 Pro, 1 CPU, 12 logical and 12 physical cores -.NET SDK 9.0.301 - [Host] : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD - DefaultJob : .NET 8.0.14 (8.0.1425.11118), Arm64 RyuJIT AdvSIMD - - -``` -| Method | BatchCount | OperationsPerInvoke | Mean | Error | StdDev | Gen0 | Allocated | -|------------------------- |----------- |-------------------- |-------------:|------------:|------------:|-------:|----------:| -| **EnqueueAndFlush** | **10** | **100** | **1,793.4 ns** | **13.75 ns** | **12.86 ns** | **0.6104** | **5 KB** | -| EnqueueAndFlush_Parallel | 10 | 100 | 18,550.8 ns | 368.24 ns | 889.34 ns | 1.1292 | 9.16 KB | -| **EnqueueAndFlush** | **10** | **200** | **3,679.8 ns** | **18.65 ns** | **16.53 ns** | **1.2207** | **10 KB** | -| EnqueueAndFlush_Parallel | 10 | 200 | 41,246.4 ns | 508.07 ns | 475.25 ns | 1.7090 | 14.04 KB | -| **EnqueueAndFlush** | **10** | **1000** | **17,239.1 ns** | **62.50 ns** | **58.46 ns** | **6.1035** | **50 KB** | -| EnqueueAndFlush_Parallel | 10 | 1000 | 192,059.3 ns | 956.92 ns | 895.11 ns | 4.3945 | 37.52 KB | -| **EnqueueAndFlush** | **100** | **100** | **866.7 ns** | **1.99 ns** | **1.77 ns** | **0.1469** | **1.2 KB** | -| EnqueueAndFlush_Parallel | 100 | 100 | 6,714.8 ns | 100.75 ns | 94.24 ns | 0.5569 | 4.52 KB | -| **EnqueueAndFlush** | **100** | **200** | **1,714.5 ns** | **3.20 ns** | **3.00 ns** | **0.2937** | **2.41 KB** | -| EnqueueAndFlush_Parallel | 100 | 200 | 43,842.8 ns | 860.74 ns | 1,718.99 ns | 0.9155 | 7.51 KB | -| **EnqueueAndFlush** | **100** | **1000** | **8,537.8 ns** | **9.80 ns** | **9.17 ns** | **1.4648** | **12.03 KB** | -| EnqueueAndFlush_Parallel | 100 | 1000 | 313,421.4 ns | 6,159.27 ns | 6,846.01 ns | 1.9531 | 18.37 KB | diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs index 889dcde450..92ddd4e09f 100644 --- a/samples/Sentry.Samples.Console.Basic/Program.cs +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -4,11 +4,14 @@ * - Performance Tracing (Transactions / Spans) * - Release Health (Sessions) * - Logs + * - Metrics * - MSBuild integration for Source Context (see the csproj) * * For more advanced features of the SDK, see Sentry.Samples.Console.Customized. */ +using System.Diagnostics; +using System.Net; using System.Net.Http; using static System.Console; @@ -50,6 +53,25 @@ // Drop logs with level Info return log.Level is SentryLogLevel.Info ? null : log; }); + + // Sentry (trace-connected) Metrics via SentrySdk.Experimental.Metrics are enabled by default. + options.Experimental.SetBeforeSendMetric(static metric => + { + if (metric.TryGetValue(out int integer) && integer < 0) + { + // Return null to drop the metric + return null; + } + + // A demonstration of how you can modify the metric object before sending it to Sentry + if (metric.Type is SentryMetricType.Counter) + { + metric.SetAttribute("operating_system.platform", Environment.OSVersion.Platform.ToString()); + metric.SetAttribute("operating_system.version", Environment.OSVersion.Version.ToString()); + } + + return metric; + }); }); // This starts a new transaction and attaches it to the scope. @@ -71,9 +93,25 @@ async Task FirstFunction() // This is an example of making an HttpRequest. A trace us automatically captured by Sentry for this. var messageHandler = new SentryHttpMessageHandler(); var httpClient = new HttpClient(messageHandler, true); + + var stopwatch = Stopwatch.StartNew(); var html = await httpClient.GetStringAsync("https://example.com/"); + stopwatch.Stop(); + WriteLine(html); + + // Info-Log filtered via "BeforeSendLog" callback SentrySdk.Logger.LogInfo("HTTP Request completed."); + + // Counter-Metric prevented from being sent to Sentry via "BeforeSendMetric" callback + SentrySdk.Experimental.Metrics.EmitCounter("sentry.samples.console.basic.ignore", -1); + + // Counter-Metric modified before sending it to Sentry via "BeforeSendMetric" callback + SentrySdk.Experimental.Metrics.EmitCounter("sentry.samples.console.basic.http_requests_completed", 1); + + // Distribution-Metric sent as is (see "BeforeSendMetric" callback) + SentrySdk.Experimental.Metrics.EmitDistribution("sentry.samples.console.basic.http_request_duration", stopwatch.Elapsed.TotalSeconds, MeasurementUnit.Duration.Second, + [new KeyValuePair("http.request.method", HttpMethod.Get.Method), new KeyValuePair("http.response.status_code", (int)HttpStatusCode.OK)]); } async Task SecondFunction() diff --git a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs index 6567811762..e4f5b500d9 100644 --- a/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs +++ b/src/Sentry.Extensions.Logging/SentryStructuredLogger.cs @@ -41,7 +41,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except } var timestamp = _clock.GetUtcNow(); - SentryLog.GetTraceIdAndSpanId(_hub, out var traceId, out var spanId); + _hub.GetTraceIdAndSpanId(out var traceId, out var spanId); var level = logLevel.ToSentryLogLevel(); Debug.Assert(level != default); diff --git a/src/Sentry.Serilog/SentrySink.Structured.cs b/src/Sentry.Serilog/SentrySink.Structured.cs index cdff9166ac..535c4d1656 100644 --- a/src/Sentry.Serilog/SentrySink.Structured.cs +++ b/src/Sentry.Serilog/SentrySink.Structured.cs @@ -7,7 +7,7 @@ internal sealed partial class SentrySink { private static void CaptureStructuredLog(IHub hub, SentryOptions options, LogEvent logEvent, string formatted, string? template) { - SentryLog.GetTraceIdAndSpanId(hub, out var traceId, out var spanId); + hub.GetTraceIdAndSpanId(out var traceId, out var spanId); GetStructuredLoggingParametersAndAttributes(logEvent, out var parameters, out var attributes); SentryLog log = new(logEvent.Timestamp, traceId, logEvent.Level.ToSentryLogLevel(), formatted) diff --git a/src/Sentry/BindableSentryOptions.cs b/src/Sentry/BindableSentryOptions.cs index bbadddaf33..52abdf7df9 100644 --- a/src/Sentry/BindableSentryOptions.cs +++ b/src/Sentry/BindableSentryOptions.cs @@ -1,7 +1,7 @@ namespace Sentry; /// -/// Contains representations of the subset of properties in SentryOptions that can be set from ConfigurationBindings. +/// Contains representations of the subset of properties in that can be set from ConfigurationBindings. /// Note that all of these properties are nullable, so that if they are not present in configuration, the values from /// the type being bound to will be preserved. /// @@ -56,6 +56,8 @@ internal partial class BindableSentryOptions public bool? EnableSpotlight { get; set; } public string? SpotlightUrl { get; set; } + public ExperimentalSentryOptions? Experimental { get; set; } + public void ApplyTo(SentryOptions options) { options.IsGlobalModeEnabled = IsGlobalModeEnabled ?? options.IsGlobalModeEnabled; @@ -106,6 +108,11 @@ public void ApplyTo(SentryOptions options) options.EnableSpotlight = EnableSpotlight ?? options.EnableSpotlight; options.SpotlightUrl = SpotlightUrl ?? options.SpotlightUrl; + if (Experimental is { } experimental) + { + options.Experimental.EnableMetrics = experimental.EnableMetrics ?? options.Experimental.EnableMetrics; + } + #if ANDROID Android.ApplyTo(options.Android); Native.ApplyTo(options.Native); @@ -113,4 +120,12 @@ public void ApplyTo(SentryOptions options) Native.ApplyTo(options.Native); #endif } + + /// + /// Bindable Options for . + /// + internal class ExperimentalSentryOptions + { + public bool? EnableMetrics { get; set; } + } } diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index e8115c03f9..03f70ff591 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -1,6 +1,5 @@ using Sentry.Internal; using Sentry.Protocol.Envelopes; -using Sentry.Protocol.Metrics; namespace Sentry.Extensibility; @@ -267,4 +266,10 @@ public void Dispose() /// Disabled Logger. /// public SentryStructuredLogger Logger => DisabledSentryStructuredLogger.Instance; + + /// + /// Disabled Metrics. + /// + [Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat = "https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public SentryMetricEmitter Metrics => DisabledSentryMetricEmitter.Instance; } diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs index 45499369e6..2db6900259 100644 --- a/src/Sentry/Extensibility/HubAdapter.cs +++ b/src/Sentry/Extensibility/HubAdapter.cs @@ -1,6 +1,5 @@ using Sentry.Infrastructure; using Sentry.Protocol.Envelopes; -using Sentry.Protocol.Metrics; namespace Sentry.Extensibility; @@ -37,6 +36,12 @@ private HubAdapter() { } /// public SentryStructuredLogger Logger { [DebuggerStepThrough] get => SentrySdk.Logger; } + /// + /// Forwards the call to . + /// + [Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat = "https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public SentryMetricEmitter Metrics { [DebuggerStepThrough] get => SentrySdk.Experimental.Metrics; } + /// /// Forwards the call to . /// diff --git a/src/Sentry/HubExtensions.cs b/src/Sentry/HubExtensions.cs index 4414c34a64..f48081d0c2 100644 --- a/src/Sentry/HubExtensions.cs +++ b/src/Sentry/HubExtensions.cs @@ -287,4 +287,34 @@ internal static ITransactionTracer StartTransaction( hub.ConfigureScope(scope => current = scope); return current; } + + /// + /// Get of either the currently active Span, or the current Scope. + /// Get only if there is a currently active Span. + /// + /// + /// Intended for use by and . + /// + internal static void GetTraceIdAndSpanId(this IHub hub, out SentryId traceId, out SpanId? spanId) + { + var activeSpan = hub.GetSpan(); + if (activeSpan is not null) + { + traceId = activeSpan.TraceId; + spanId = activeSpan.SpanId; + return; + } + + var scope = hub.GetScope(); + if (scope is not null) + { + traceId = scope.PropagationContext.TraceId; + spanId = null; + return; + } + + Debug.Assert(hub is not Hub, "In case of a 'full' Hub, there is always a Scope. Otherwise (disabled) there is no Scope, but this branch should be unreachable."); + traceId = SentryId.Empty; + spanId = null; + } } diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index 076f454f3f..a5a7afd9c9 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -29,6 +29,19 @@ public interface IHub : ISentryClient, ISentryScopeManager /// public SentryStructuredLogger Logger { get; } + /// + /// Generates and sends metrics to Sentry. + /// + /// + /// Available options: + /// + /// + /// + /// + /// + [Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat = "https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public SentryMetricEmitter Metrics { get; } + /// /// Starts a transaction. /// diff --git a/src/Sentry/Internal/StructuredLogBatchBuffer.cs b/src/Sentry/Internal/BatchBuffer.cs similarity index 83% rename from src/Sentry/Internal/StructuredLogBatchBuffer.cs rename to src/Sentry/Internal/BatchBuffer.cs index a9f49216b7..78ae1ab063 100644 --- a/src/Sentry/Internal/StructuredLogBatchBuffer.cs +++ b/src/Sentry/Internal/BatchBuffer.cs @@ -3,7 +3,7 @@ namespace Sentry.Internal; /// -/// A wrapper over an , intended for reusable buffering. +/// A wrapper over an , intended for reusable buffering for . /// /// /// Must be attempted to flush via when either the is reached, @@ -12,15 +12,15 @@ namespace Sentry.Internal; /// allowing multiple threads for or exclusive access for . /// [DebuggerDisplay("Name = {Name}, Capacity = {Capacity}, Additions = {_additions}, AddCount = {AddCount}, IsDisposed = {_disposed}")] -internal sealed class StructuredLogBatchBuffer : IDisposable +internal sealed class BatchBuffer : IDisposable { - private readonly SentryLog[] _array; + private readonly TItem[] _array; private int _additions; private readonly ScopedCountdownLock _addLock; private readonly Timer _timer; private readonly TimeSpan _timeout; - private readonly Action _timeoutExceededAction; + private readonly Action> _timeoutExceededAction; private volatile bool _disposed; @@ -31,12 +31,12 @@ internal sealed class StructuredLogBatchBuffer : IDisposable /// When the timeout exceeds after an item has been added and the not yet been exceeded, is invoked. /// The operation to execute when the exceeds if the buffer is neither empty nor full. /// Name of the new buffer. - public StructuredLogBatchBuffer(int capacity, TimeSpan timeout, Action timeoutExceededAction, string? name = null) + public BatchBuffer(int capacity, TimeSpan timeout, Action> timeoutExceededAction, string? name = null) { ThrowIfLessThanTwo(capacity, nameof(capacity)); ThrowIfNegativeOrZero(timeout, nameof(timeout)); - _array = new SentryLog[capacity]; + _array = new TItem[capacity]; _additions = 0; _addLock = new ScopedCountdownLock(); @@ -72,18 +72,18 @@ public StructuredLogBatchBuffer(int capacity, TimeSpan timeout, Action /// Element attempted to be added. - /// An describing the result of the thread-safe operation. - internal StructuredLogBatchBufferAddStatus Add(SentryLog item) + /// An describing the result of the thread-safe operation. + internal BatchBufferAddStatus Add(TItem item) { if (_disposed) { - return StructuredLogBatchBufferAddStatus.IgnoredIsDisposed; + return BatchBufferAddStatus.IgnoredIsDisposed; } using var scope = _addLock.TryEnterCounterScope(); if (!scope.IsEntered) { - return StructuredLogBatchBufferAddStatus.IgnoredIsFlushing; + return BatchBufferAddStatus.IgnoredIsFlushing; } var count = Interlocked.Increment(ref _additions); @@ -92,24 +92,24 @@ internal StructuredLogBatchBufferAddStatus Add(SentryLog item) { EnableTimer(); _array[count - 1] = item; - return StructuredLogBatchBufferAddStatus.AddedFirst; + return BatchBufferAddStatus.AddedFirst; } if (count < _array.Length) { _array[count - 1] = item; - return StructuredLogBatchBufferAddStatus.Added; + return BatchBufferAddStatus.Added; } if (count == _array.Length) { DisableTimer(); _array[count - 1] = item; - return StructuredLogBatchBufferAddStatus.AddedLast; + return BatchBufferAddStatus.AddedLast; } Debug.Assert(count > _array.Length); - return StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded; + return BatchBufferAddStatus.IgnoredCapacityExceeded; } /// @@ -157,7 +157,7 @@ internal void OnIntervalElapsed(object? state) /// Returns a new Array consisting of the elements successfully added. /// /// An Array with Length of successful additions. - private SentryLog[] ToArrayAndClear() + private TItem[] ToArrayAndClear() { var additions = _additions; var length = _array.Length; @@ -173,7 +173,7 @@ private SentryLog[] ToArrayAndClear() /// /// The Length of the buffer a new Array is created from. /// An Array with Length of . - private SentryLog[] ToArrayAndClear(int length) + private TItem[] ToArrayAndClear(int length) { Debug.Assert(_addLock.IsSet); @@ -182,14 +182,14 @@ private SentryLog[] ToArrayAndClear(int length) return array; } - private SentryLog[] ToArray(int length) + private TItem[] ToArray(int length) { if (length == 0) { - return Array.Empty(); + return Array.Empty(); } - var array = new SentryLog[length]; + var array = new TItem[length]; Array.Copy(_array, array, length); return array; } @@ -253,17 +253,17 @@ static void ThrowNegativeOrZero(TimeSpan value, string paramName) /// A scope than ensures only a single operation is in progress, /// and blocks the calling thread until all operations have finished. /// When is , no more can be started, - /// which will then return immediately. + /// which will then return immediately. /// /// /// Only when scope . /// internal ref struct FlushScope : IDisposable { - private StructuredLogBatchBuffer? _lockObj; + private BatchBuffer? _lockObj; private ScopedCountdownLock.LockScope _scope; - internal FlushScope(StructuredLogBatchBuffer lockObj, ScopedCountdownLock.LockScope scope) + internal FlushScope(BatchBuffer lockObj, ScopedCountdownLock.LockScope scope) { Debug.Assert(scope.IsEntered); _lockObj = lockObj; @@ -272,7 +272,7 @@ internal FlushScope(StructuredLogBatchBuffer lockObj, ScopedCountdownLock.LockSc internal bool IsEntered => _scope.IsEntered; - internal SentryLog[] Flush() + internal TItem[] Flush() { var lockObj = _lockObj; if (lockObj is not null) @@ -300,7 +300,7 @@ public void Dispose() } } -internal enum StructuredLogBatchBufferAddStatus : byte +internal enum BatchBufferAddStatus : byte { AddedFirst, Added, diff --git a/src/Sentry/Internal/BatchProcessor.cs b/src/Sentry/Internal/BatchProcessor.cs new file mode 100644 index 0000000000..e34148bac9 --- /dev/null +++ b/src/Sentry/Internal/BatchProcessor.cs @@ -0,0 +1,174 @@ +using Sentry.Extensibility; +using Sentry.Protocol; +using Sentry.Protocol.Envelopes; + +namespace Sentry.Internal; + +/// +/// The Sentry Batch Processor. +/// +/// +/// Uses a double buffer strategy to achieve synchronous and lock-free adding. +/// Switches the active buffer either when full or timeout exceeded (after first item added). +/// Items are dropped when both buffers are either full or being flushed. +/// Items are not enqueued when the Hub is disabled (Hub is being or has been disposed). +/// Flushing blocks the calling thread until all pending add operations have completed. +/// +/// Implementation: +/// - When Hub is disabled (i.e. disposed), does not enqueue item +/// - Try to enqueue item into currently active buffer +/// - when currently active buffer is full, try to enqueue item into the other buffer +/// - when the other buffer is also full, or currently being flushed, then the item is dropped and a discarded event is recorded as a client report +/// - Swap currently active buffer when +/// - buffer is full +/// - timeout has exceeded +/// - Batch and Capture items after swapping currently active buffer +/// - wait until all pending add/enqueue operations have completed (required for timeout) +/// - flush the buffer and capture an envelope containing the batched items +/// - After flush, items can be enqueued again into the buffer +/// +/// +/// Sentry Batch Processor +/// OpenTelemetry Batch Processor +/// Sentry Logs +/// Sentry Metrics +internal abstract class BatchProcessor : IDisposable where TItem : notnull +{ + private readonly IHub _hub; + private readonly IClientReportRecorder _clientReportRecorder; + private readonly IDiagnosticLogger? _diagnosticLogger; + + private readonly BatchBuffer _buffer1; + private readonly BatchBuffer _buffer2; + private volatile BatchBuffer _activeBuffer; + + protected BatchProcessor(IHub hub, int batchCount, TimeSpan batchInterval, IClientReportRecorder clientReportRecorder, IDiagnosticLogger? diagnosticLogger) + { + _hub = hub; + _clientReportRecorder = clientReportRecorder; + _diagnosticLogger = diagnosticLogger; + + _buffer1 = new BatchBuffer(batchCount, batchInterval, OnTimeoutExceeded, "Buffer 1"); + _buffer2 = new BatchBuffer(batchCount, batchInterval, OnTimeoutExceeded, "Buffer 2"); + _activeBuffer = _buffer1; + } + + internal void Enqueue(TItem item) + { + if (!_hub.IsEnabled) + { + return; + } + + var activeBuffer = _activeBuffer; + + if (!TryEnqueue(activeBuffer, item)) + { + activeBuffer = ReferenceEquals(activeBuffer, _buffer1) ? _buffer2 : _buffer1; + if (!TryEnqueue(activeBuffer, item)) + { + _clientReportRecorder.RecordDiscardedEvent(DiscardReason.Backpressure, DataCategory.Default, 1); + _diagnosticLogger?.LogInfo("{0}-Buffer full ... dropping {0}", item.GetType().Name); + } + } + } + + internal void Flush() + { + CaptureItems(_buffer1); + CaptureItems(_buffer2); + } + + /// + /// Forces invocation of a Timeout of the active buffer. + /// + /// + /// Intended for Testing only. + /// + internal void OnIntervalElapsed() + { + var activeBuffer = _activeBuffer; + activeBuffer.OnIntervalElapsed(activeBuffer); + } + + private bool TryEnqueue(BatchBuffer buffer, TItem item) + { + var status = buffer.Add(item); + + if (status is BatchBufferAddStatus.AddedLast) + { + SwapActiveBuffer(buffer); + CaptureItems(buffer); + return true; + } + + return status is BatchBufferAddStatus.AddedFirst or BatchBufferAddStatus.Added; + } + + private void SwapActiveBuffer(BatchBuffer currentActiveBuffer) + { + var newActiveBuffer = ReferenceEquals(currentActiveBuffer, _buffer1) ? _buffer2 : _buffer1; + _ = Interlocked.CompareExchange(ref _activeBuffer, newActiveBuffer, currentActiveBuffer); + } + + private void CaptureItems(BatchBuffer buffer) + { + TItem[]? items = null; + + using (var scope = buffer.TryEnterFlushScope()) + { + if (scope.IsEntered) + { + items = scope.Flush(); + } + } + + if (items is not null && items.Length != 0) + { + CaptureEnvelope(_hub, items); + } + } + + protected abstract void CaptureEnvelope(IHub hub, TItem[] items); + + private void OnTimeoutExceeded(BatchBuffer buffer) + { + if (!buffer.IsEmpty) + { + SwapActiveBuffer(buffer); + CaptureItems(buffer); + } + } + + public void Dispose() + { + _buffer1.Dispose(); + _buffer2.Dispose(); + } +} + +internal sealed class SentryLogBatchProcessor : BatchProcessor +{ + internal SentryLogBatchProcessor(IHub hub, int batchCount, TimeSpan batchInterval, IClientReportRecorder clientReportRecorder, IDiagnosticLogger? diagnosticLogger) + : base(hub, batchCount, batchInterval, clientReportRecorder, diagnosticLogger) + { + } + + protected override void CaptureEnvelope(IHub hub, SentryLog[] items) + { + _ = hub.CaptureEnvelope(Envelope.FromLog(new StructuredLog(items))); + } +} + +internal sealed class SentryMetricBatchProcessor : BatchProcessor +{ + internal SentryMetricBatchProcessor(IHub hub, int batchCount, TimeSpan batchInterval, IClientReportRecorder clientReportRecorder, IDiagnosticLogger? diagnosticLogger) + : base(hub, batchCount, batchInterval, clientReportRecorder, diagnosticLogger) + { + } + + protected override void CaptureEnvelope(IHub hub, SentryMetric[] items) + { + _ = hub.CaptureEnvelope(Envelope.FromMetric(new TraceMetric(items))); + } +} diff --git a/src/Sentry/Internal/DefaultSentryMetricEmitter.cs b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs new file mode 100644 index 0000000000..b8e25964ca --- /dev/null +++ b/src/Sentry/Internal/DefaultSentryMetricEmitter.cs @@ -0,0 +1,102 @@ +using Sentry.Extensibility; +using Sentry.Infrastructure; + +namespace Sentry.Internal; + +internal sealed class DefaultSentryMetricEmitter : SentryMetricEmitter, IDisposable +{ + private readonly IHub _hub; + private readonly SentryOptions _options; + private readonly ISystemClock _clock; + + private readonly BatchProcessor _batchProcessor; + + internal DefaultSentryMetricEmitter(IHub hub, SentryOptions options, ISystemClock clock, int batchCount, TimeSpan batchInterval) + { + Debug.Assert(hub.IsEnabled); + Debug.Assert(options.Experimental is { EnableMetrics: true }); + + _hub = hub; + _options = options; + _clock = clock; + + _batchProcessor = new SentryMetricBatchProcessor(hub, batchCount, batchInterval, _options.ClientReportRecorder, _options.DiagnosticLogger); + } + + /// + private protected override void CaptureMetric(SentryMetricType type, string name, T value, string? unit, IEnumerable>? attributes, Scope? scope) where T : struct + { + if (!SentryMetric.IsSupported(typeof(T))) + { + _options.DiagnosticLogger?.LogWarning("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double.", typeof(T)); + return; + } + + if (string.IsNullOrEmpty(name)) + { + _options.DiagnosticLogger?.LogWarning("Name of metrics cannot be null or empty. Metric-Type: {0}; Value-Type: {1}", type.ToString(), typeof(T)); + return; + } + + var metric = SentryMetric.Create(_hub, _options, _clock, type, name, value, unit, attributes, scope); + CaptureMetric(metric); + } + + /// + private protected override void CaptureMetric(SentryMetricType type, string name, T value, string? unit, ReadOnlySpan> attributes, Scope? scope) where T : struct + { + if (!SentryMetric.IsSupported(typeof(T))) + { + _options.DiagnosticLogger?.LogWarning("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double.", typeof(T)); + return; + } + + if (string.IsNullOrEmpty(name)) + { + _options.DiagnosticLogger?.LogWarning("Name of metrics cannot be null or empty. Metric-Type: {0}; Value-Type: {1}", type.ToString(), typeof(T)); + return; + } + + var metric = SentryMetric.Create(_hub, _options, _clock, type, name, value, unit, attributes, scope); + CaptureMetric(metric); + } + + /// + private protected override void CaptureMetric(SentryMetric metric) where T : struct + { + Debug.Assert(SentryMetric.IsSupported(typeof(T))); + Debug.Assert(!string.IsNullOrEmpty(metric.Name)); + + SentryMetric? configuredMetric = metric; + + if (_options.Experimental.BeforeSendMetricInternal is { } beforeSendMetric) + { + try + { + configuredMetric = beforeSendMetric.Invoke(metric); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogError(e, "The BeforeSendMetric callback threw an exception. The Metric will be dropped."); + return; + } + } + + if (configuredMetric is not null) + { + _batchProcessor.Enqueue(configuredMetric); + } + } + + /// + protected internal override void Flush() + { + _batchProcessor.Flush(); + } + + /// + public void Dispose() + { + _batchProcessor.Dispose(); + } +} diff --git a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs index 3e42cc7005..1d74b6dc5e 100644 --- a/src/Sentry/Internal/DefaultSentryStructuredLogger.cs +++ b/src/Sentry/Internal/DefaultSentryStructuredLogger.cs @@ -9,7 +9,7 @@ internal sealed class DefaultSentryStructuredLogger : SentryStructuredLogger, ID private readonly SentryOptions _options; private readonly ISystemClock _clock; - private readonly StructuredLogBatchProcessor _batchProcessor; + private readonly BatchProcessor _batchProcessor; internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemClock clock, int batchCount, TimeSpan batchInterval) { @@ -20,14 +20,14 @@ internal DefaultSentryStructuredLogger(IHub hub, SentryOptions options, ISystemC _options = options; _clock = clock; - _batchProcessor = new StructuredLogBatchProcessor(hub, batchCount, batchInterval, _options.ClientReportRecorder, _options.DiagnosticLogger); + _batchProcessor = new SentryLogBatchProcessor(hub, batchCount, batchInterval, _options.ClientReportRecorder, _options.DiagnosticLogger); } /// private protected override void CaptureLog(SentryLogLevel level, string template, object[]? parameters, Action? configureLog) { var timestamp = _clock.GetUtcNow(); - SentryLog.GetTraceIdAndSpanId(_hub, out var traceId, out var spanId); + _hub.GetTraceIdAndSpanId(out var traceId, out var spanId); string message; try diff --git a/src/Sentry/Internal/DisabledSentryMetricEmitter.cs b/src/Sentry/Internal/DisabledSentryMetricEmitter.cs new file mode 100644 index 0000000000..1afef5e90b --- /dev/null +++ b/src/Sentry/Internal/DisabledSentryMetricEmitter.cs @@ -0,0 +1,34 @@ +namespace Sentry.Internal; + +internal sealed class DisabledSentryMetricEmitter : SentryMetricEmitter +{ + internal static DisabledSentryMetricEmitter Instance { get; } = new DisabledSentryMetricEmitter(); + + internal DisabledSentryMetricEmitter() + { + } + + /// + private protected override void CaptureMetric(SentryMetricType type, string name, T value, string? unit, IEnumerable>? attributes, Scope? scope) where T : struct + { + // disabled + } + + /// + private protected override void CaptureMetric(SentryMetricType type, string name, T value, string? unit, ReadOnlySpan> attributes, Scope? scope) where T : struct + { + // disabled + } + + /// + private protected override void CaptureMetric(SentryMetric metric) where T : struct + { + // disabled + } + + /// + protected internal override void Flush() + { + // disabled + } +} diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index f44a288b33..6d10090183 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -1,6 +1,5 @@ using Sentry.Extensibility; using Sentry.Infrastructure; -using Sentry.Integrations; using Sentry.Internal.Extensions; using Sentry.Protocol.Envelopes; using Sentry.Protocol.Metrics; @@ -82,6 +81,7 @@ internal Hub( } Logger = SentryStructuredLogger.Create(this, options, _clock); + Metrics = SentryMetricEmitter.Create(this, options, _clock); #if MEMORY_DUMP_SUPPORTED if (options.HeapDumpOptions is not null) @@ -819,6 +819,7 @@ public async Task FlushAsync(TimeSpan timeout) try { Logger.Flush(); + Metrics.Flush(); await CurrentClient.FlushAsync(timeout).ConfigureAwait(false); } catch (Exception e) @@ -853,7 +854,9 @@ public void Dispose() #endif Logger.Flush(); + Metrics.Flush(); (Logger as IDisposable)?.Dispose(); // see Sentry.Internal.DefaultSentryStructuredLogger + (Metrics as IDisposable)?.Dispose(); // see Sentry.Internal.DefaultSentryMetricEmitter try { @@ -884,4 +887,6 @@ public void Dispose() public SentryId LastEventId => CurrentScope.LastEventId; public SentryStructuredLogger Logger { get; } + + public SentryMetricEmitter Metrics { get; } } diff --git a/src/Sentry/Internal/Polyfills.cs b/src/Sentry/Internal/Polyfills.cs index 2113687f8d..f5ce518ec3 100644 --- a/src/Sentry/Internal/Polyfills.cs +++ b/src/Sentry/Internal/Polyfills.cs @@ -62,3 +62,32 @@ public static void WriteRawValue(this Utf8JsonWriter writer, byte[] utf8Json) } } #endif + +// TODO: remove when updating Polyfill: https://github.com/getsentry/sentry-dotnet/pull/4879 +#if !NET6_0_OR_GREATER +internal static class EnumerableExtensions +{ + internal static bool TryGetNonEnumeratedCount(this IEnumerable source, out int count) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is ICollection genericCollection) + { + count = genericCollection.Count; + return true; + } + + if (source is ICollection collection) + { + count = collection.Count; + return true; + } + + count = 0; + return false; + } +} +#endif diff --git a/src/Sentry/Internal/StructuredLogBatchProcessor.cs b/src/Sentry/Internal/StructuredLogBatchProcessor.cs deleted file mode 100644 index 2fe5db924e..0000000000 --- a/src/Sentry/Internal/StructuredLogBatchProcessor.cs +++ /dev/null @@ -1,145 +0,0 @@ -using Sentry.Extensibility; -using Sentry.Protocol; -using Sentry.Protocol.Envelopes; - -namespace Sentry.Internal; - -/// -/// The Batch Processor for Sentry Logs. -/// -/// -/// Uses a double buffer strategy to achieve synchronous and lock-free adding. -/// Switches the active buffer either when full or timeout exceeded (after first item added). -/// Logs are dropped when both buffers are either full or being flushed. -/// Logs are not enqueued when the Hub is disabled (Hub is being or has been disposed). -/// Flushing blocks the calling thread until all pending add operations have completed. -/// -/// Implementation: -/// - When Hub is disabled (i.e. disposed), does not enqueue log -/// - Try to enqueue log into currently active buffer -/// - when currently active buffer is full, try to enqueue log into the other buffer -/// - when the other buffer is also full, or currently being flushed, then the log is dropped and a discarded event is recorded as a client report -/// - Swap currently active buffer when -/// - buffer is full -/// - timeout has exceeded -/// - Batch and Capture logs after swapping currently active buffer -/// - wait until all pending add/enqueue operations have completed (required for timeout) -/// - flush the buffer and capture an envelope containing the batched logs -/// - After flush, logs can be enqueued again into the buffer -/// -/// -/// Sentry Logs -/// Sentry Batch Processor -/// OpenTelemetry Batch Processor -internal sealed class StructuredLogBatchProcessor : IDisposable -{ - private readonly IHub _hub; - private readonly IClientReportRecorder _clientReportRecorder; - private readonly IDiagnosticLogger? _diagnosticLogger; - - private readonly StructuredLogBatchBuffer _buffer1; - private readonly StructuredLogBatchBuffer _buffer2; - private volatile StructuredLogBatchBuffer _activeBuffer; - - public StructuredLogBatchProcessor(IHub hub, int batchCount, TimeSpan batchInterval, IClientReportRecorder clientReportRecorder, IDiagnosticLogger? diagnosticLogger) - { - _hub = hub; - _clientReportRecorder = clientReportRecorder; - _diagnosticLogger = diagnosticLogger; - - _buffer1 = new StructuredLogBatchBuffer(batchCount, batchInterval, OnTimeoutExceeded, "Buffer 1"); - _buffer2 = new StructuredLogBatchBuffer(batchCount, batchInterval, OnTimeoutExceeded, "Buffer 2"); - _activeBuffer = _buffer1; - } - - internal void Enqueue(SentryLog log) - { - if (!_hub.IsEnabled) - { - return; - } - - var activeBuffer = _activeBuffer; - - if (!TryEnqueue(activeBuffer, log)) - { - activeBuffer = ReferenceEquals(activeBuffer, _buffer1) ? _buffer2 : _buffer1; - if (!TryEnqueue(activeBuffer, log)) - { - _clientReportRecorder.RecordDiscardedEvent(DiscardReason.Backpressure, DataCategory.Default, 1); - _diagnosticLogger?.LogInfo("Log Buffer full ... dropping log"); - } - } - } - - internal void Flush() - { - CaptureLogs(_buffer1); - CaptureLogs(_buffer2); - } - - /// - /// Forces invocation of a Timeout of the active buffer. - /// - /// - /// Intended for Testing only. - /// - internal void OnIntervalElapsed() - { - var activeBuffer = _activeBuffer; - activeBuffer.OnIntervalElapsed(activeBuffer); - } - - private bool TryEnqueue(StructuredLogBatchBuffer buffer, SentryLog log) - { - var status = buffer.Add(log); - - if (status is StructuredLogBatchBufferAddStatus.AddedLast) - { - SwapActiveBuffer(buffer); - CaptureLogs(buffer); - return true; - } - - return status is StructuredLogBatchBufferAddStatus.AddedFirst or StructuredLogBatchBufferAddStatus.Added; - } - - private void SwapActiveBuffer(StructuredLogBatchBuffer currentActiveBuffer) - { - var newActiveBuffer = ReferenceEquals(currentActiveBuffer, _buffer1) ? _buffer2 : _buffer1; - _ = Interlocked.CompareExchange(ref _activeBuffer, newActiveBuffer, currentActiveBuffer); - } - - private void CaptureLogs(StructuredLogBatchBuffer buffer) - { - SentryLog[]? logs = null; - - using (var scope = buffer.TryEnterFlushScope()) - { - if (scope.IsEntered) - { - logs = scope.Flush(); - } - } - - if (logs is not null && logs.Length != 0) - { - _ = _hub.CaptureEnvelope(Envelope.FromLog(new StructuredLog(logs))); - } - } - - private void OnTimeoutExceeded(StructuredLogBatchBuffer buffer) - { - if (!buffer.IsEmpty) - { - SwapActiveBuffer(buffer); - CaptureLogs(buffer); - } - } - - public void Dispose() - { - _buffer1.Dispose(); - _buffer2.Dispose(); - } -} diff --git a/src/Sentry/MeasurementUnit.cs b/src/Sentry/MeasurementUnit.cs index b572e335bd..48fe7452e5 100644 --- a/src/Sentry/MeasurementUnit.cs +++ b/src/Sentry/MeasurementUnit.cs @@ -75,6 +75,8 @@ internal static MeasurementUnit Parse(string? name) /// public override string ToString() => _unit?.ToString().ToLowerInvariant() ?? _name ?? ""; + internal string? ToNullableString() => _unit?.ToString().ToLowerInvariant() ?? _name; + /// public bool Equals(MeasurementUnit other) => Equals(_unit, other._unit) && _name == other._name; diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index e9b06cff00..e3e85da713 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -446,6 +446,18 @@ internal static Envelope FromLog(StructuredLog log) return new Envelope(header, items); } + internal static Envelope FromMetric(TraceMetric metric) + { + var header = DefaultHeader; + + var items = new[] + { + EnvelopeItem.FromMetric(metric), + }; + + return new Envelope(header, items); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 976c9a1305..21ab3ba25d 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -25,6 +25,7 @@ public sealed class EnvelopeItem : ISerializable, IDisposable internal const string TypeValueMetric = "statsd"; internal const string TypeValueCodeLocations = "metric_meta"; internal const string TypeValueLog = "log"; + internal const string TypeValueTraceMetric = "trace_metric"; private const string LengthKey = "length"; private const string FileNameKey = "filename"; @@ -369,6 +370,18 @@ internal static EnvelopeItem FromLog(StructuredLog log) return new EnvelopeItem(header, new JsonSerializable(log)); } + internal static EnvelopeItem FromMetric(TraceMetric metric) + { + var header = new Dictionary(3, StringComparer.Ordinal) + { + [TypeKey] = TypeValueTraceMetric, + ["item_count"] = metric.Length, + ["content_type"] = "application/vnd.sentry.items.trace-metric+json", + }; + + return new EnvelopeItem(header, new JsonSerializable(metric)); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/TraceMetric.cs b/src/Sentry/Protocol/TraceMetric.cs new file mode 100644 index 0000000000..2cb63d5c5a --- /dev/null +++ b/src/Sentry/Protocol/TraceMetric.cs @@ -0,0 +1,37 @@ +using Sentry.Extensibility; + +namespace Sentry.Protocol; + +/// +/// Represents the Sentry protocol for Trace-connected Metrics. +/// +/// +/// Sentry Docs: . +/// Sentry Developer Documentation: . +/// +internal sealed class TraceMetric : ISentryJsonSerializable +{ + private readonly SentryMetric[] _items; + + public TraceMetric(SentryMetric[] metrics) + { + _items = metrics; + } + + public int Length => _items.Length; + public ReadOnlySpan Items => _items; + + public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + writer.WriteStartArray("items"); + + foreach (var metric in _items) + { + metric.WriteTo(writer, logger); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } +} diff --git a/src/Sentry/SentryLog.cs b/src/Sentry/SentryLog.cs index c57a08a84d..315b0a8a56 100644 --- a/src/Sentry/SentryLog.cs +++ b/src/Sentry/SentryLog.cs @@ -1,5 +1,4 @@ using Sentry.Extensibility; -using Sentry.Internal; using Sentry.Protocol; namespace Sentry; @@ -245,29 +244,4 @@ internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteEndObject(); } - - internal static void GetTraceIdAndSpanId(IHub hub, out SentryId traceId, out SpanId? spanId) - { - var activeSpan = hub.GetSpan(); - if (activeSpan is not null) - { - traceId = activeSpan.TraceId; - spanId = activeSpan.SpanId; - return; - } - - // set "span_id" to the ID of the Span that was active when the Log was collected - // do not set "span_id" if there was no active Span - spanId = null; - - var scope = hub.GetScope(); - if (scope is not null) - { - traceId = scope.PropagationContext.TraceId; - return; - } - - Debug.Assert(hub is not Hub, "In case of a 'full' Hub, there is always a Scope. Otherwise (disabled) there is no Scope, but this branch should be unreachable."); - traceId = SentryId.Empty; - } } diff --git a/src/Sentry/SentryMetric.Factory.cs b/src/Sentry/SentryMetric.Factory.cs new file mode 100644 index 0000000000..fe6d0e7b2d --- /dev/null +++ b/src/Sentry/SentryMetric.Factory.cs @@ -0,0 +1,54 @@ +using Sentry.Infrastructure; + +namespace Sentry; + +public abstract partial class SentryMetric +{ + private static SentryMetric CreateCore(IHub hub, SentryOptions options, ISystemClock clock, SentryMetricType type, string name, T value, string? unit, Scope? scope) where T : struct + { + Debug.Assert(IsSupported()); + Debug.Assert(!string.IsNullOrEmpty(name)); + Debug.Assert(type is not SentryMetricType.Counter || unit is null, $"'{nameof(unit)}' is only used for Metrics of type {nameof(SentryMetricType.Gauge)} and {nameof(SentryMetricType.Distribution)}."); + + var timestamp = clock.GetUtcNow(); + hub.GetTraceIdAndSpanId(out var traceId, out var spanId); + + var metric = new SentryMetric(timestamp, traceId, type, name, value) + { + SpanId = spanId, + Unit = unit, + }; + + scope ??= hub.GetScope(); + metric.SetDefaultAttributes(options, scope?.Sdk ?? SdkVersion.Instance); + + return metric; + } + + internal static SentryMetric Create(IHub hub, SentryOptions options, ISystemClock clock, SentryMetricType type, string name, T value, string? unit, IEnumerable>? attributes, Scope? scope) where T : struct + { + var metric = CreateCore(hub, options, clock, type, name, value, unit, scope); + metric.SetAttributes(attributes); + return metric; + } + + internal static SentryMetric Create(IHub hub, SentryOptions options, ISystemClock clock, SentryMetricType type, string name, T value, string? unit, ReadOnlySpan> attributes, Scope? scope) where T : struct + { + var metric = CreateCore(hub, options, clock, type, name, value, unit, scope); + metric.SetAttributes(attributes); + return metric; + } + + private static bool IsSupported() where T : struct + { + var valueType = typeof(T); + return IsSupported(valueType); + } + + internal static bool IsSupported(Type valueType) + { + return valueType == typeof(long) || valueType == typeof(double) + || valueType == typeof(int) || valueType == typeof(float) + || valueType == typeof(short) || valueType == typeof(byte); + } +} diff --git a/src/Sentry/SentryMetric.Generic.cs b/src/Sentry/SentryMetric.Generic.cs new file mode 100644 index 0000000000..9e38adfb62 --- /dev/null +++ b/src/Sentry/SentryMetric.Generic.cs @@ -0,0 +1,72 @@ +using Sentry.Extensibility; + +namespace Sentry; + +/// +/// Internal generic representation of . +/// +/// The numeric type of the metric. +/// +/// We hide some of the generic implementation details from user code. +/// +internal sealed class SentryMetric : SentryMetric where T : struct +{ + private readonly T _value; + + [SetsRequiredMembers] + internal SentryMetric(DateTimeOffset timestamp, SentryId traceId, SentryMetricType type, string name, T value) + : base(timestamp, traceId, type, name) + { + _value = value; + } + + internal override object Value => _value; + + /// + public override bool TryGetValue(out TValue value) where TValue : struct + { + if (_value is TValue match) + { + value = match; + return true; + } + + value = default; + return false; + } + + private protected override void WriteMetricValueTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + const string propertyName = "value"; + var type = typeof(T); + + if (type == typeof(long)) + { + writer.WriteNumber(propertyName, (long)(object)_value); + } + else if (type == typeof(double)) + { + writer.WriteNumber(propertyName, (double)(object)_value); + } + else if (type == typeof(int)) + { + writer.WriteNumber(propertyName, (int)(object)_value); + } + else if (type == typeof(float)) + { + writer.WriteNumber(propertyName, (float)(object)_value); + } + else if (type == typeof(short)) + { + writer.WriteNumber(propertyName, (short)(object)_value); + } + else if (type == typeof(byte)) + { + writer.WriteNumber(propertyName, (byte)(object)_value); + } + else + { + Debug.Fail($"Unhandled Metric Type {typeof(T)}.", "This instruction should be unreachable."); + } + } +} diff --git a/src/Sentry/SentryMetric.cs b/src/Sentry/SentryMetric.cs new file mode 100644 index 0000000000..ae56f0e019 --- /dev/null +++ b/src/Sentry/SentryMetric.cs @@ -0,0 +1,317 @@ +using Sentry.Extensibility; +using Sentry.Protocol; + +namespace Sentry; + +/// +/// Represents a Sentry Trace-connected Metric. +/// +/// +/// Sentry Docs: . +/// Sentry Developer Documentation: . +/// Sentry .NET SDK Docs: . +/// +[DebuggerDisplay(@"SentryMetric \{ Type = {Type}, Name = '{Name}', Value = {Value} \}")] +public abstract partial class SentryMetric +{ + private readonly Dictionary _attributes; + + [SetsRequiredMembers] + private protected SentryMetric(DateTimeOffset timestamp, SentryId traceId, SentryMetricType type, string name) + { + Timestamp = timestamp; + TraceId = traceId; + Type = type; + Name = name; + // 7 is the number of built-in attributes, so we start with that. + _attributes = new Dictionary(7); + } + + /// + /// Timestamp indicating when the metric was recorded. + /// + /// + /// Sent as seconds since the Unix epoch. + /// + public required DateTimeOffset Timestamp { get; init; } + + /// + /// The trace id of the metric. + /// + public required SentryId TraceId { get; init; } + + /// + /// The type of metric. + /// + /// + /// One of: + /// + /// + /// Type + /// Description + /// + /// + /// counter + /// A metric that increments counts. + /// + /// + /// gauge + /// A metric that tracks a value that can go up or down. + /// + /// + /// distribution + /// A metric that tracks the statistical distribution of values. + /// + /// + /// + public required SentryMetricType Type { get; init; } + + /// + /// The name of the metric. + /// + /// + /// This should follow a hierarchical naming convention using dots as separators (e.g., api.response_time, db.query.duration). + /// + public required string Name { get; init; } + + /// + /// The numeric value of the metric. + /// + /// + /// The interpretation depends on the metric type: + /// + /// + /// Type + /// Description + /// + /// + /// counter + /// The count to increment by (should default to ). + /// + /// + /// gauge + /// The current value. + /// + /// + /// distribution + /// A single measured value. + /// + /// + /// + // Internal non-generic (boxed to object) read-only property for testing, and usage in DebuggerDisplayAttribute. + internal abstract object Value { get; } + + /// + /// The span id of the span that was active when the metric was emitted. + /// + public SpanId? SpanId { get; init; } + + /// + /// The unit of measurement for the metric value. + /// + /// + /// Only used for and . + /// + public string? Unit { get; init; } + + /// + /// Gets the metric value if it is of the specified type . + /// + /// When this method returns, contains the metric value, if it is of the specified type ; otherwise, the value for the type of the parameter. This parameter is passed uninitialized. + /// The numeric type of the metric. + /// if this is of type ; otherwise, . + /// Supported numeric value types for are , , , , , and . + public abstract bool TryGetValue(out TValue value) where TValue : struct; + + /// + /// Gets the attribute value associated with the specified key. + /// + /// + /// Returns if this contains an attribute with the specified key which is of type and it's value is not . + /// Otherwise . + /// Supported types: + /// + /// + /// Type + /// Range + /// + /// + /// string + /// and + /// + /// + /// boolean + /// and + /// + /// + /// integer + /// 64-bit signed integral numeric types + /// + /// + /// double + /// 64-bit floating-point numeric types + /// + /// + /// Unsupported types: + /// + /// + /// Type + /// Result + /// + /// + /// + /// ToString as "type": "string" + /// + /// + /// Collections + /// ToString as "type": "string" + /// + /// + /// + /// ignored + /// + /// + /// + /// + public bool TryGetAttribute(string key, [MaybeNullWhen(false)] out TAttribute value) + { + if (_attributes.TryGetValue(key, out var attribute) && attribute.Value is TAttribute attributeValue) + { + value = attributeValue; + return true; + } + + value = default; + return false; + } + + /// + /// Set a key-value pair of data attached to the metric. + /// + public void SetAttribute(string key, TAttribute value) where TAttribute : notnull + { + if (value is null) + { + return; + } + + _attributes[key] = new SentryAttribute(value); + } + + internal void SetAttribute(string key, string value) + { + _attributes[key] = new SentryAttribute(value, "string"); + } + + internal void SetAttribute(string key, char value) + { + _attributes[key] = new SentryAttribute(value.ToString(), "string"); + } + + internal void SetAttribute(string key, int value) + { + _attributes[key] = new SentryAttribute(value, "integer"); + } + + internal void SetDefaultAttributes(SentryOptions options, SdkVersion sdk) + { + var environment = options.SettingLocator.GetEnvironment(); + SetAttribute("sentry.environment", environment); + + var release = options.SettingLocator.GetRelease(); + if (release is not null) + { + SetAttribute("sentry.release", release); + } + + if (sdk.Name is { } name) + { + SetAttribute("sentry.sdk.name", name); + } + if (sdk.Version is { } version) + { + SetAttribute("sentry.sdk.version", version); + } + } + + internal void SetAttributes(IEnumerable>? attributes) + { + if (attributes is null) + { + return; + } + +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + if (attributes.TryGetNonEnumeratedCount(out var count)) + { + _ = _attributes.EnsureCapacity(_attributes.Count + count); + } +#endif + + foreach (var attribute in attributes) + { + _attributes[attribute.Key] = new SentryAttribute(attribute.Value); + } + } + + internal void SetAttributes(ReadOnlySpan> attributes) + { + if (attributes.IsEmpty) + { + return; + } + +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + _ = _attributes.EnsureCapacity(_attributes.Count + attributes.Length); +#endif + + foreach (var attribute in attributes) + { + _attributes[attribute.Key] = new SentryAttribute(attribute.Value); + } + } + + /// + internal void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) + { + writer.WriteStartObject(); + +#if NET9_0_OR_GREATER + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeMilliseconds() / (double)TimeSpan.MillisecondsPerSecond); +#else + writer.WriteNumber("timestamp", Timestamp.ToUnixTimeMilliseconds() / 1_000.0); +#endif + + writer.WriteString("type", Type.ToProtocolString(logger)); + writer.WriteString("name", Name); + WriteMetricValueTo(writer, logger); + + writer.WritePropertyName("trace_id"); + TraceId.WriteTo(writer, logger); + + if (SpanId.HasValue) + { + writer.WritePropertyName("span_id"); + SpanId.Value.WriteTo(writer, logger); + } + + if (!string.IsNullOrEmpty(Unit)) + { + writer.WriteString("unit", Unit); + } + + writer.WritePropertyName("attributes"); + writer.WriteStartObject(); + + foreach (var attribute in _attributes) + { + SentryAttributeSerializer.WriteAttribute(writer, attribute.Key, attribute.Value, logger); + } + + writer.WriteEndObject(); + + writer.WriteEndObject(); + } + + private protected abstract void WriteMetricValueTo(Utf8JsonWriter writer, IDiagnosticLogger? logger); +} diff --git a/src/Sentry/SentryMetricEmitter.Counter.cs b/src/Sentry/SentryMetricEmitter.Counter.cs new file mode 100644 index 0000000000..74dd577a5c --- /dev/null +++ b/src/Sentry/SentryMetricEmitter.Counter.cs @@ -0,0 +1,57 @@ +namespace Sentry; + +public abstract partial class SentryMetricEmitter +{ + /// + /// Increment a counter. + /// + /// The name of the metric. + /// The value of the metric. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitCounter(string name, T value) where T : struct + { + CaptureMetric(SentryMetricType.Counter, name, value, null, [], null); + } + + /// + /// Increment a counter. + /// + /// The name of the metric. + /// The value of the metric. + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitCounter(string name, T value, Scope? scope) where T : struct + { + CaptureMetric(SentryMetricType.Counter, name, value, null, [], scope); + } + + /// + /// Increment a counter. + /// + /// The name of the metric. + /// The value of the metric. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitCounter(string name, T value, IEnumerable>? attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Counter, name, value, null, attributes, scope); + } + + /// + /// Increment a counter. + /// + /// The name of the metric. + /// The value of the metric. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitCounter(string name, T value, ReadOnlySpan> attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Counter, name, value, null, attributes, scope); + } +} diff --git a/src/Sentry/SentryMetricEmitter.Distribution.cs b/src/Sentry/SentryMetricEmitter.Distribution.cs new file mode 100644 index 0000000000..862dd6ce15 --- /dev/null +++ b/src/Sentry/SentryMetricEmitter.Distribution.cs @@ -0,0 +1,150 @@ +namespace Sentry; + +public abstract partial class SentryMetricEmitter +{ + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitDistribution(string name, T value) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, null, [], null); + } + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + [Obsolete(ObsoleteStringUnitForwardCompatibility)] + public void EmitDistribution(string name, T value, string? unit) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, unit, [], null); + } + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitDistribution(string name, T value, MeasurementUnit unit) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, unit.ToNullableString(), [], null); + } + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitDistribution(string name, T value, Scope? scope) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, null, [], scope); + } + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + [Obsolete(ObsoleteStringUnitForwardCompatibility)] + public void EmitDistribution(string name, T value, string? unit, Scope? scope) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, unit, [], scope); + } + + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitDistribution(string name, T value, MeasurementUnit unit, Scope? scope) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, unit.ToNullableString(), [], scope); + } + + + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + [Obsolete(ObsoleteStringUnitForwardCompatibility)] + public void EmitDistribution(string name, T value, string? unit, IEnumerable>? attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, unit, attributes, scope); + } + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitDistribution(string name, T value, MeasurementUnit unit, IEnumerable>? attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, unit.ToNullableString(), attributes, scope); + } + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + [Obsolete(ObsoleteStringUnitForwardCompatibility)] + public void EmitDistribution(string name, T value, string? unit, ReadOnlySpan> attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, unit, attributes, scope); + } + + /// + /// Add a distribution value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitDistribution(string name, T value, MeasurementUnit unit, ReadOnlySpan> attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Distribution, name, value, unit.ToNullableString(), attributes, scope); + } +} diff --git a/src/Sentry/SentryMetricEmitter.Gauge.cs b/src/Sentry/SentryMetricEmitter.Gauge.cs new file mode 100644 index 0000000000..5bafa4e242 --- /dev/null +++ b/src/Sentry/SentryMetricEmitter.Gauge.cs @@ -0,0 +1,147 @@ +namespace Sentry; + +public abstract partial class SentryMetricEmitter +{ + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitGauge(string name, T value) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, null, [], null); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + [Obsolete(ObsoleteStringUnitForwardCompatibility)] + public void EmitGauge(string name, T value, string? unit) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, unit, [], null); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitGauge(string name, T value, MeasurementUnit unit) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, unit.ToNullableString(), [], null); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitGauge(string name, T value, Scope? scope) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, null, [], scope); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + [Obsolete(ObsoleteStringUnitForwardCompatibility)] + public void EmitGauge(string name, T value, string? unit, Scope? scope) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, unit, [], scope); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitGauge(string name, T value, MeasurementUnit unit, Scope? scope) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, unit.ToNullableString(), [], scope); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + [Obsolete(ObsoleteStringUnitForwardCompatibility)] + public void EmitGauge(string name, T value, string? unit, IEnumerable>? attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, unit, attributes, scope); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitGauge(string name, T value, MeasurementUnit unit, IEnumerable>? attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, unit.ToNullableString(), attributes, scope); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + [Obsolete(ObsoleteStringUnitForwardCompatibility)] + public void EmitGauge(string name, T value, string? unit, ReadOnlySpan> attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, unit, attributes, scope); + } + + /// + /// Set a gauge value. + /// + /// The name of the metric. + /// The value of the metric. + /// The unit of measurement. + /// A dictionary of attributes (key-value pairs with type information). + /// The scope to capture the metric with. + /// The numeric type of the metric. + /// Supported numeric value types for are , , , , , and . + public void EmitGauge(string name, T value, MeasurementUnit unit, ReadOnlySpan> attributes, Scope? scope = null) where T : struct + { + CaptureMetric(SentryMetricType.Gauge, name, value, unit.ToNullableString(), attributes, scope); + } +} diff --git a/src/Sentry/SentryMetricEmitter.cs b/src/Sentry/SentryMetricEmitter.cs new file mode 100644 index 0000000000..69bc521469 --- /dev/null +++ b/src/Sentry/SentryMetricEmitter.cs @@ -0,0 +1,69 @@ +using Sentry.Infrastructure; +using Sentry.Internal; + +namespace Sentry; + +/// +/// Creates and sends metrics to Sentry. +/// +public abstract partial class SentryMetricEmitter +{ + internal static SentryMetricEmitter Create(IHub hub, SentryOptions options, ISystemClock clock) + => Create(hub, options, clock, 100, TimeSpan.FromSeconds(5)); + + internal static SentryMetricEmitter Create(IHub hub, SentryOptions options, ISystemClock clock, int batchCount, TimeSpan batchInterval) + { + return options.Experimental.EnableMetrics + ? new DefaultSentryMetricEmitter(hub, options, clock, batchCount, batchInterval) + : DisabledSentryMetricEmitter.Instance; + } + + private protected SentryMetricEmitter() + { + } + + /// + /// Buffers a Sentry Metric item + /// via the associated Batch Processor. + /// + /// The type of metric. + /// The name of the metric. + /// The numeric value of the metric. + /// The unit of measurement for the metric value. + /// A dictionary of key-value pairs of arbitrary data attached to the metric. + /// The optional scope to capture the metric with. + /// The numeric type of the metric. + private protected abstract void CaptureMetric(SentryMetricType type, string name, T value, string? unit, IEnumerable>? attributes, Scope? scope) where T : struct; + + /// + /// Buffers a Sentry Metric item + /// via the associated Batch Processor. + /// + /// The type of metric. + /// The name of the metric. + /// The numeric value of the metric. + /// The unit of measurement for the metric value. + /// A dictionary of key-value pairs of arbitrary data attached to the metric. + /// The optional scope to capture the metric with. + /// The numeric type of the metric. + private protected abstract void CaptureMetric(SentryMetricType type, string name, T value, string? unit, ReadOnlySpan> attributes, Scope? scope) where T : struct; + + /// + /// Buffers a Sentry Metric item + /// via the associated Batch Processor. + /// + /// The metric. + /// The numeric type of the metric. + private protected abstract void CaptureMetric(SentryMetric metric) where T : struct; + + /// + /// Clears all buffers for this metrics and causes any buffered metrics to be sent by the underlying . + /// + protected internal abstract void Flush(); +} + +public abstract partial class SentryMetricEmitter +{ + internal const string ObsoleteStringUnitForwardCompatibility = + $"Custom units may be supported in the future. The {nameof(String)}-based overloads are for forward compatibility. The {nameof(MeasurementUnit)}-based overloads are currently preferred."; +} diff --git a/src/Sentry/SentryMetricType.cs b/src/Sentry/SentryMetricType.cs new file mode 100644 index 0000000000..d40a062077 --- /dev/null +++ b/src/Sentry/SentryMetricType.cs @@ -0,0 +1,54 @@ +using Sentry.Extensibility; + +namespace Sentry; + +/// +/// The type of metric. +/// +public enum SentryMetricType +{ + /// + /// A metric that increments counts. + /// + /// + /// represents the count to increment by. + /// By default: . + /// + Counter, + + /// + /// A metric that tracks a value that can go up or down. + /// + /// + /// represents the current value. + /// + Gauge, + + /// + /// A metric that tracks the statistical distribution of values. + /// + /// + /// represents a single measured value. + /// + Distribution, +} + +internal static class SentryMetricTypeExtensions +{ + internal static string ToProtocolString(this SentryMetricType type, IDiagnosticLogger? logger) + { + return type switch + { + SentryMetricType.Counter => "counter", + SentryMetricType.Gauge => "gauge", + SentryMetricType.Distribution => "distribution", + _ => IsNotDefined(type, logger), + }; + + static string IsNotDefined(SentryMetricType type, IDiagnosticLogger? logger) + { + logger?.LogDebug("Metric type {0} is not defined.", type); + return "unknown"; + } + } +} diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index c769b98370..7fc69a600f 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -1905,4 +1905,51 @@ internal static List GetDefaultInAppExclude() => "ServiceStack", "Java.Interop", ]; + + /// + /// Sentry features that are currently in an experimental state. + /// + /// + /// Experimental features are subject to binary, source and behavioral breaking changes in future updates. + /// + public ExperimentalSentryOptions Experimental { get; } = new ExperimentalSentryOptions(); + + /// + /// Sentry features that are currently in an experimental state. + /// + /// + /// Experimental features are subject to binary, source and behavioral breaking changes in future updates. + /// + public class ExperimentalSentryOptions + { + private Func? _beforeSendMetric; + + internal ExperimentalSentryOptions() + { + } + + internal Func? BeforeSendMetricInternal => _beforeSendMetric; + + /// + /// When set to , the SDK does not generate and send metrics to Sentry via . + /// Defaults to . + /// + /// + public bool EnableMetrics { get; set; } = true; + + /// + /// Sets a callback function to be invoked before sending the metric to Sentry. + /// When the delegate throws an during invocation, the metric will not be captured. + /// + /// + /// It can be used to modify the metric object before being sent to Sentry. + /// To prevent the metric from being sent to Sentry, return . + /// Supported numeric value types are , , , , , and . + /// + /// + public void SetBeforeSendMetric(Func beforeSendMetric) + { + _beforeSendMetric = beforeSendMetric; + } + } } diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index 46da99658e..a7a5f7b096 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -857,4 +857,30 @@ public static void CauseCrash(CrashType crashType) [DllImport("libc", EntryPoint = "strlen")] private static extern IntPtr NativeStrlenLibC(IntPtr strt); #endif + + /// + /// Sentry features that are currently in an experimental state. + /// + /// + /// Experimental features are subject to binary, source and behavioral breaking changes in future updates. + /// + public static ExperimentalSentrySdk Experimental { get; } = new(); + + /// + /// Sentry features that are currently in an experimental state. + /// + /// + /// Experimental features are subject to binary, source and behavioral breaking changes in future updates. + /// + public sealed class ExperimentalSentrySdk + { + internal ExperimentalSentrySdk() + { + } + +#pragma warning disable SENTRYTRACECONNECTEDMETRICS + /// + public SentryMetricEmitter Metrics { [DebuggerStepThrough] get => CurrentHub.Metrics; } +#pragma warning restore SENTRYTRACECONNECTEDMETRICS + } } diff --git a/test/Sentry.Testing/BindableTests.cs b/test/Sentry.Testing/BindableTests.cs index 68dd553a36..69968238c8 100644 --- a/test/Sentry.Testing/BindableTests.cs +++ b/test/Sentry.Testing/BindableTests.cs @@ -32,6 +32,7 @@ private static IEnumerable GetBindableProperties(IEnumerable Entries { get; } = new(); + internal List Metrics { get; } = new(); + + /// + private protected override void CaptureMetric(SentryMetricType type, string name, T value, string? unit, IEnumerable>? attributes, Scope? scope) where T : struct + { + Entries.Add(MetricEntry.Create(type, name, value, unit, attributes, scope)); + } + + /// + private protected override void CaptureMetric(SentryMetricType type, string name, T value, string? unit, ReadOnlySpan> attributes, Scope? scope) where T : struct + { + Entries.Add(MetricEntry.Create(type, name, value, unit, attributes.ToArray(), scope)); + } + + /// + private protected override void CaptureMetric(SentryMetric metric) where T : struct + { + Metrics.Add(metric); + } + + /// + protected internal override void Flush() + { + // no-op + } + + public sealed class MetricEntry : IEquatable + { + public static MetricEntry Create(SentryMetricType type, string name, object value, string? unit, IEnumerable>? attributes, Scope? scope) + { + return new MetricEntry(type, name, value, unit, attributes is null ? ImmutableDictionary.Empty : attributes.ToImmutableDictionary(), scope); + } + + private MetricEntry(SentryMetricType type, string name, object value, string? unit, ImmutableDictionary attributes, Scope? scope) + { + Type = type; + Name = name; + Value = value; + Unit = unit; + Attributes = attributes; + Scope = scope; + } + + public SentryMetricType Type { get; } + public string Name { get; } + public object Value { get; } + public string? Unit { get; } + public ImmutableDictionary Attributes { get; } + public Scope? Scope { get; } + + public void AssertEqual(SentryMetricType type, string name, object value) + { + var expected = Create(type, name, value, null, null, null); + Assert.Equal(expected, this); + } + + public void AssertEqual(SentryMetricType type, string name, object value, string? unit, IEnumerable>? attributes, Scope? scope) + { + var expected = Create(type, name, value, unit, attributes, scope); + Assert.Equal(expected, this); + } + + public bool Equals(MetricEntry? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Type == other.Type + && string.Equals(Name, other.Name, StringComparison.Ordinal) + && Value.Equals(other.Value) + && string.Equals(Unit, other.Unit, StringComparison.Ordinal) + && Attributes.SequenceEqual(other.Attributes, AttributeEqualityComparer.Instance) + && ReferenceEquals(Scope, other.Scope); + } + + public override bool Equals(object? obj) + { + return obj is MetricEntry other && Equals(other); + } + + public override int GetHashCode() + { + throw new UnreachableException(); + } + } + + private sealed class AttributeEqualityComparer : IEqualityComparer> + { + public static AttributeEqualityComparer Instance { get; } = new AttributeEqualityComparer(); + + private AttributeEqualityComparer() + { + } + + public bool Equals(KeyValuePair x, KeyValuePair y) + { + return string.Equals(x.Key, y.Key, StringComparison.Ordinal) + && Equals(x.Value, y.Value); + } + + public int GetHashCode(KeyValuePair obj) + { + return HashCode.Combine(obj.Key, obj.Value); + } + } +} diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 95396f0d23..64869d4587 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -197,6 +197,8 @@ namespace Sentry bool IsSessionActive { get; } Sentry.SentryId LastEventId { get; } Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + Sentry.SentryMetricEmitter Metrics { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -659,6 +661,103 @@ namespace Sentry protected override System.Net.Http.HttpResponseMessage Send(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } } + [System.Diagnostics.DebuggerDisplay("SentryMetric \\{ Type = {Type}, Name = \'{Name}\', Value = {Value} \\}")] + public abstract class SentryMetric + { + public required string Name { get; init; } + public Sentry.SpanId? SpanId { get; init; } + public required System.DateTimeOffset Timestamp { get; init; } + public required Sentry.SentryId TraceId { get; init; } + public required Sentry.SentryMetricType Type { get; init; } + public string? Unit { get; init; } + public void SetAttribute(string key, TAttribute value) + where TAttribute : notnull { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out TAttribute value) { } + public abstract bool TryGetValue(out TValue value) + where TValue : struct; + } + public abstract class SentryMetricEmitter + { + public void EmitCounter(string name, T value) + where T : struct { } + public void EmitCounter(string name, T value, Sentry.Scope? scope) + where T : struct { } + public void EmitCounter(string name, T value, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitCounter(string name, T value, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitDistribution(string name, T value) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, Sentry.Scope? scope) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitGauge(string name, T value) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, Sentry.Scope? scope) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + protected abstract void Flush(); + } + public enum SentryMetricType + { + Counter = 0, + Gauge = 1, + Distribution = 2, + } public enum SentryMonitorInterval { Year = 0, @@ -710,6 +809,7 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + public Sentry.SentryOptions.ExperimentalSentryOptions Experimental { get; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -797,6 +897,11 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + public class ExperimentalSentryOptions + { + public bool EnableMetrics { get; set; } + public void SetBeforeSendMetric(System.Func beforeSendMetric) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -826,6 +931,7 @@ namespace Sentry } public static class SentrySdk { + public static Sentry.SentrySdk.ExperimentalSentrySdk Experimental { get; } public static bool IsEnabled { get; } public static bool IsSessionActive { get; } public static Sentry.SentryId LastEventId { get; } @@ -889,6 +995,10 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + public sealed class ExperimentalSentrySdk + { + public Sentry.SentryMetricEmitter Metrics { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -1380,6 +1490,8 @@ namespace Sentry.Extensibility public bool IsSessionActive { get; } public Sentry.SentryId LastEventId { get; } public Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public Sentry.SentryMetricEmitter Metrics { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1427,6 +1539,8 @@ namespace Sentry.Extensibility public bool IsSessionActive { get; } public Sentry.SentryId LastEventId { get; } public Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public Sentry.SentryMetricEmitter 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 95396f0d23..64869d4587 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -197,6 +197,8 @@ namespace Sentry bool IsSessionActive { get; } Sentry.SentryId LastEventId { get; } Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + Sentry.SentryMetricEmitter Metrics { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -659,6 +661,103 @@ namespace Sentry protected override System.Net.Http.HttpResponseMessage Send(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } } + [System.Diagnostics.DebuggerDisplay("SentryMetric \\{ Type = {Type}, Name = \'{Name}\', Value = {Value} \\}")] + public abstract class SentryMetric + { + public required string Name { get; init; } + public Sentry.SpanId? SpanId { get; init; } + public required System.DateTimeOffset Timestamp { get; init; } + public required Sentry.SentryId TraceId { get; init; } + public required Sentry.SentryMetricType Type { get; init; } + public string? Unit { get; init; } + public void SetAttribute(string key, TAttribute value) + where TAttribute : notnull { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out TAttribute value) { } + public abstract bool TryGetValue(out TValue value) + where TValue : struct; + } + public abstract class SentryMetricEmitter + { + public void EmitCounter(string name, T value) + where T : struct { } + public void EmitCounter(string name, T value, Sentry.Scope? scope) + where T : struct { } + public void EmitCounter(string name, T value, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitCounter(string name, T value, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitDistribution(string name, T value) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, Sentry.Scope? scope) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitGauge(string name, T value) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, Sentry.Scope? scope) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + protected abstract void Flush(); + } + public enum SentryMetricType + { + Counter = 0, + Gauge = 1, + Distribution = 2, + } public enum SentryMonitorInterval { Year = 0, @@ -710,6 +809,7 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + public Sentry.SentryOptions.ExperimentalSentryOptions Experimental { get; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -797,6 +897,11 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + public class ExperimentalSentryOptions + { + public bool EnableMetrics { get; set; } + public void SetBeforeSendMetric(System.Func beforeSendMetric) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -826,6 +931,7 @@ namespace Sentry } public static class SentrySdk { + public static Sentry.SentrySdk.ExperimentalSentrySdk Experimental { get; } public static bool IsEnabled { get; } public static bool IsSessionActive { get; } public static Sentry.SentryId LastEventId { get; } @@ -889,6 +995,10 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + public sealed class ExperimentalSentrySdk + { + public Sentry.SentryMetricEmitter Metrics { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -1380,6 +1490,8 @@ namespace Sentry.Extensibility public bool IsSessionActive { get; } public Sentry.SentryId LastEventId { get; } public Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public Sentry.SentryMetricEmitter Metrics { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1427,6 +1539,8 @@ namespace Sentry.Extensibility public bool IsSessionActive { get; } public Sentry.SentryId LastEventId { get; } public Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public Sentry.SentryMetricEmitter 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.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 95396f0d23..64869d4587 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -197,6 +197,8 @@ namespace Sentry bool IsSessionActive { get; } Sentry.SentryId LastEventId { get; } Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + Sentry.SentryMetricEmitter Metrics { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -659,6 +661,103 @@ namespace Sentry protected override System.Net.Http.HttpResponseMessage Send(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } } + [System.Diagnostics.DebuggerDisplay("SentryMetric \\{ Type = {Type}, Name = \'{Name}\', Value = {Value} \\}")] + public abstract class SentryMetric + { + public required string Name { get; init; } + public Sentry.SpanId? SpanId { get; init; } + public required System.DateTimeOffset Timestamp { get; init; } + public required Sentry.SentryId TraceId { get; init; } + public required Sentry.SentryMetricType Type { get; init; } + public string? Unit { get; init; } + public void SetAttribute(string key, TAttribute value) + where TAttribute : notnull { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out TAttribute value) { } + public abstract bool TryGetValue(out TValue value) + where TValue : struct; + } + public abstract class SentryMetricEmitter + { + public void EmitCounter(string name, T value) + where T : struct { } + public void EmitCounter(string name, T value, Sentry.Scope? scope) + where T : struct { } + public void EmitCounter(string name, T value, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitCounter(string name, T value, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitDistribution(string name, T value) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, Sentry.Scope? scope) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitGauge(string name, T value) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, Sentry.Scope? scope) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + protected abstract void Flush(); + } + public enum SentryMetricType + { + Counter = 0, + Gauge = 1, + Distribution = 2, + } public enum SentryMonitorInterval { Year = 0, @@ -710,6 +809,7 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + public Sentry.SentryOptions.ExperimentalSentryOptions Experimental { get; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -797,6 +897,11 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + public class ExperimentalSentryOptions + { + public bool EnableMetrics { get; set; } + public void SetBeforeSendMetric(System.Func beforeSendMetric) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -826,6 +931,7 @@ namespace Sentry } public static class SentrySdk { + public static Sentry.SentrySdk.ExperimentalSentrySdk Experimental { get; } public static bool IsEnabled { get; } public static bool IsSessionActive { get; } public static Sentry.SentryId LastEventId { get; } @@ -889,6 +995,10 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + public sealed class ExperimentalSentrySdk + { + public Sentry.SentryMetricEmitter Metrics { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -1380,6 +1490,8 @@ namespace Sentry.Extensibility public bool IsSessionActive { get; } public Sentry.SentryId LastEventId { get; } public Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public Sentry.SentryMetricEmitter Metrics { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1427,6 +1539,8 @@ namespace Sentry.Extensibility public bool IsSessionActive { get; } public Sentry.SentryId LastEventId { get; } public Sentry.SentryStructuredLogger Logger { get; } + [System.Diagnostics.CodeAnalysis.Experimental("SENTRYTRACECONNECTEDMETRICS", UrlFormat="https://github.com/getsentry/sentry-dotnet/discussions/4838")] + public Sentry.SentryMetricEmitter 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 f7417edfdf..fbf517dd1e 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -185,6 +185,7 @@ namespace Sentry bool IsSessionActive { get; } Sentry.SentryId LastEventId { get; } Sentry.SentryStructuredLogger Logger { get; } + Sentry.SentryMetricEmitter Metrics { get; } void BindException(System.Exception exception, Sentry.ISpan span); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, System.Action configureScope); Sentry.SentryId CaptureEvent(Sentry.SentryEvent evt, Sentry.SentryHint? hint, System.Action configureScope); @@ -646,6 +647,103 @@ namespace Sentry protected abstract Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url); protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } } + [System.Diagnostics.DebuggerDisplay("SentryMetric \\{ Type = {Type}, Name = \'{Name}\', Value = {Value} \\}")] + public abstract class SentryMetric + { + public required string Name { get; init; } + public Sentry.SpanId? SpanId { get; init; } + public required System.DateTimeOffset Timestamp { get; init; } + public required Sentry.SentryId TraceId { get; init; } + public required Sentry.SentryMetricType Type { get; init; } + public string? Unit { get; init; } + public void SetAttribute(string key, TAttribute value) + where TAttribute : notnull { } + public bool TryGetAttribute(string key, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out TAttribute value) { } + public abstract bool TryGetValue(out TValue value) + where TValue : struct; + } + public abstract class SentryMetricEmitter + { + public void EmitCounter(string name, T value) + where T : struct { } + public void EmitCounter(string name, T value, Sentry.Scope? scope) + where T : struct { } + public void EmitCounter(string name, T value, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitCounter(string name, T value, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitDistribution(string name, T value) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, Sentry.Scope? scope) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitDistribution(string name, T value, Sentry.MeasurementUnit unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitDistribution(string name, T value, string? unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitGauge(string name, T value) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, Sentry.Scope? scope) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, Sentry.Scope? scope) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + public void EmitGauge(string name, T value, Sentry.MeasurementUnit unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, System.Collections.Generic.IEnumerable>? attributes, Sentry.Scope? scope = null) + where T : struct { } + [System.Obsolete("Custom units may be supported in the future. The String-based overloads are for f" + + "orward compatibility. The MeasurementUnit-based overloads are currently preferre" + + "d.")] + public void EmitGauge(string name, T value, string? unit, System.ReadOnlySpan> attributes, Sentry.Scope? scope = null) + where T : struct { } + protected abstract void Flush(); + } + public enum SentryMetricType + { + Counter = 0, + Gauge = 1, + Distribution = 2, + } public enum SentryMonitorInterval { Year = 0, @@ -697,6 +795,7 @@ namespace Sentry public bool EnableScopeSync { get; set; } public bool EnableSpotlight { get; set; } public string? Environment { get; set; } + public Sentry.SentryOptions.ExperimentalSentryOptions Experimental { get; } public System.Collections.Generic.IList FailedRequestStatusCodes { get; set; } public System.Collections.Generic.IList FailedRequestTargets { get; set; } public System.TimeSpan FlushTimeout { get; set; } @@ -778,6 +877,11 @@ namespace Sentry public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public void SetBeforeSendTransaction(System.Func beforeSendTransaction) { } public Sentry.SentryOptions UseStackTraceFactory(Sentry.Extensibility.ISentryStackTraceFactory sentryStackTraceFactory) { } + public class ExperimentalSentryOptions + { + public bool EnableMetrics { get; set; } + public void SetBeforeSendMetric(System.Func beforeSendMetric) { } + } } public sealed class SentryPackage : Sentry.ISentryJsonSerializable { @@ -807,6 +911,7 @@ namespace Sentry } public static class SentrySdk { + public static Sentry.SentrySdk.ExperimentalSentrySdk Experimental { get; } public static bool IsEnabled { get; } public static bool IsSessionActive { get; } public static Sentry.SentryId LastEventId { get; } @@ -870,6 +975,10 @@ namespace Sentry public static Sentry.ITransactionTracer StartTransaction(string name, string operation, Sentry.SentryTraceHeader traceHeader) { } public static Sentry.ITransactionTracer StartTransaction(string name, string operation, string? description) { } public static void UnsetTag(string key) { } + public sealed class ExperimentalSentrySdk + { + public Sentry.SentryMetricEmitter Metrics { get; } + } } public class SentrySession : Sentry.ISentrySession { @@ -1361,6 +1470,7 @@ namespace Sentry.Extensibility public bool IsSessionActive { get; } public Sentry.SentryId LastEventId { get; } public Sentry.SentryStructuredLogger Logger { get; } + public Sentry.SentryMetricEmitter Metrics { get; } public void BindClient(Sentry.ISentryClient client) { } public void BindException(System.Exception exception, Sentry.ISpan span) { } public Sentry.SentryId CaptureCheckIn(string monitorSlug, Sentry.CheckInStatus status, Sentry.SentryId? sentryId = default, System.TimeSpan? duration = default, Sentry.Scope? scope = null, System.Action? configureMonitorOptions = null) { } @@ -1408,6 +1518,7 @@ namespace Sentry.Extensibility public bool IsSessionActive { get; } public Sentry.SentryId LastEventId { get; } public Sentry.SentryStructuredLogger Logger { get; } + public Sentry.SentryMetricEmitter 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/Extensibility/DisabledHubTests.cs b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs index e03f8a82a3..40d99b2ba1 100644 --- a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs +++ b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs @@ -39,4 +39,8 @@ public void CaptureEvent_EmptyGuid() [Fact] public void Logger_IsDisabled() => Assert.IsType(DisabledHub.Instance.Logger); + + [Fact] + public void Metrics_IsDisabled() + => Assert.IsType(DisabledHub.Instance.Metrics); } diff --git a/test/Sentry.Tests/Extensibility/HubAdapterTests.cs b/test/Sentry.Tests/Extensibility/HubAdapterTests.cs index 26702163cf..f6625270b5 100644 --- a/test/Sentry.Tests/Extensibility/HubAdapterTests.cs +++ b/test/Sentry.Tests/Extensibility/HubAdapterTests.cs @@ -82,6 +82,18 @@ public void Logger_MockInvoked() element => element.AssertEqual(SentryLogLevel.Warning, "Message")); } + [Fact] + public void Metrics_MockInvoked() + { + var metrics = new InMemorySentryMetricEmitter(); + Hub.Metrics.Returns(metrics); + + HubAdapter.Instance.Metrics.EmitCounter("sentry_tests.hub_adapter_tests.counter", 1); + + Assert.Collection(metrics.Entries, + element => element.AssertEqual(SentryMetricType.Counter, "sentry_tests.hub_adapter_tests.counter", 1)); + } + [Fact] public void EndSession_CrashedStatus_MockInvoked() { diff --git a/test/Sentry.Tests/HubExtensionsTests.cs b/test/Sentry.Tests/HubExtensionsTests.cs index 280518b13d..83295c2134 100644 --- a/test/Sentry.Tests/HubExtensionsTests.cs +++ b/test/Sentry.Tests/HubExtensionsTests.cs @@ -108,4 +108,56 @@ public void AddBreadcrumb_AllFields_CreatesBreadcrumb() Assert.Equal(expectedMessage, crumb.Message); Assert.Equal(expectedTimestamp, crumb.Timestamp); } + + [Fact] + public void GetTraceIdAndSpanId_WithActiveSpan_HasBothTraceIdAndSpanId() + { + // Arrange + var span = Substitute.For(); + span.TraceId.Returns(SentryId.Create()); + span.SpanId.Returns(Sentry.SpanId.Create()); + + var hub = Substitute.For(); + hub.GetSpan().Returns(span); + + // Act + hub.GetTraceIdAndSpanId(out var traceId, out var spanId); + + // Assert + traceId.Should().Be(span.TraceId); + spanId.Should().Be(span.SpanId); + } + + [Fact] + public void GetTraceIdAndSpanId_WithoutActiveSpan_HasOnlyTraceIdButNoSpanId() + { + // Arrange + var hub = Substitute.For(); + hub.GetSpan().Returns((ISpan)null); + + var scope = new Scope(); + hub.SubstituteConfigureScope(scope); + + // Act + hub.GetTraceIdAndSpanId(out var traceId, out var spanId); + + // Assert + traceId.Should().Be(scope.PropagationContext.TraceId); + spanId.Should().BeNull(); + } + + [Fact] + public void GetTraceIdAndSpanId_WithoutIds_ShouldBeUnreachable() + { + // Arrange + var hub = Substitute.For(); + hub.GetSpan().Returns((ISpan)null); + + // Act + hub.GetTraceIdAndSpanId(out var traceId, out var spanId); + + // Assert + traceId.Should().Be(SentryId.Empty); + spanId.Should().BeNull(); + } } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 17c3474705..c8bb6ea702 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1879,6 +1879,124 @@ public void Logger_Dispose_DoesCaptureLog() hub.Logger.Should().BeOfType(); } + [Fact] + public void Metrics_IsDisabled_DoesNotCaptureMetric() + { + // Arrange + _fixture.Options.Experimental.EnableMetrics = false; + var hub = _fixture.GetSut(); + + // Act + hub.Metrics.EmitCounter("sentry_tests.hub_tests.counter", 1); + hub.Metrics.Flush(); + + // Assert + _fixture.Client.Received(0).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("trace_metric")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + hub.Metrics.Should().BeOfType(); + } + + [Fact] + public void Metrics_IsEnabled_DoesCaptureMetric() + { + // Arrange + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var hub = _fixture.GetSut(); + + // Act + hub.Metrics.EmitCounter("sentry_tests.hub_tests.counter", 1); + hub.Metrics.Flush(); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("trace_metric")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + hub.Metrics.Should().BeOfType(); + } + + [Fact] + public void Metrics_EnableAfterCreate_HasNoEffect() + { + // Arrange + _fixture.Options.Experimental.EnableMetrics = false; + var hub = _fixture.GetSut(); + + // Act + _fixture.Options.Experimental.EnableMetrics = true; + + // Assert + hub.Metrics.Should().BeOfType(); + } + + [Fact] + public void Metrics_DisableAfterCreate_HasNoEffect() + { + // Arrange + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var hub = _fixture.GetSut(); + + // Act + _fixture.Options.Experimental.EnableMetrics = false; + + // Assert + hub.Metrics.Should().BeOfType(); + } + + [Fact] + public async Task Metrics_FlushAsync_DoesCaptureMetric() + { + // Arrange + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var hub = _fixture.GetSut(); + + // Act + hub.Metrics.EmitCounter("sentry_tests.hub_tests.counter", 1); + await hub.FlushAsync(); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("trace_metric")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + await _fixture.Client.Received(1).FlushAsync( + Arg.Is(timeout => + timeout.Equals(_fixture.Options.FlushTimeout) + ) + ); + hub.Metrics.Should().BeOfType(); + } + + [Fact] + public void Metrics_Dispose_DoesCaptureMetric() + { + // Arrange + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var hub = _fixture.GetSut(); + + // Act + hub.Metrics.EmitCounter("sentry_tests.hub_tests.counter", 1); + hub.Dispose(); + + // Assert + _fixture.Client.Received(1).CaptureEnvelope( + Arg.Is(envelope => + envelope.Items.Single(item => item.Header["type"].Equals("trace_metric")).Payload.GetType().IsAssignableFrom(typeof(JsonSerializable)) + ) + ); + _fixture.Client.Received(1).FlushAsync( + Arg.Is(timeout => + timeout.Equals(_fixture.Options.ShutdownTimeout) + ) + ); + hub.Metrics.Should().BeOfType(); + } + [Fact] public void Dispose_IsEnabled_SetToFalse() { diff --git a/test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs b/test/Sentry.Tests/Internals/BatchBufferTests.cs similarity index 82% rename from test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs rename to test/Sentry.Tests/Internals/BatchBufferTests.cs index 77d8d34ebd..51cdec4512 100644 --- a/test/Sentry.Tests/Internals/StructuredLogBatchBufferTests.cs +++ b/test/Sentry.Tests/Internals/BatchBufferTests.cs @@ -2,7 +2,12 @@ namespace Sentry.Tests.Internals; -public class StructuredLogBatchBufferTests +/// +/// (formerly "Sentry.Internal.StructuredLogBatchBuffer") was originally developed as Batch Buffer for Logs only. +/// When adding support for Trace-connected Metrics, which are quite similar to Logs, it has been made generic to support both. +/// These tests are still using , rather than . +/// +public class BatchBufferTests { private sealed class Fixture { @@ -10,14 +15,14 @@ private sealed class Fixture public TimeSpan Timeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan; public string? Name { get; set; } - public List TimeoutExceededInvocations { get; } = []; + public List> TimeoutExceededInvocations { get; } = []; - public StructuredLogBatchBuffer GetSut() + public BatchBuffer GetSut() { - return new StructuredLogBatchBuffer(Capacity, Timeout, OnTimeoutExceeded, Name); + return new BatchBuffer(Capacity, Timeout, OnTimeoutExceeded, Name); } - private void OnTimeoutExceeded(StructuredLogBatchBuffer buffer) + private void OnTimeoutExceeded(BatchBuffer buffer) { TimeoutExceededInvocations.Add(buffer); } @@ -72,16 +77,16 @@ public void Add_CapacityTwo_CanAddTwice() buffer.Capacity.Should().Be(2); buffer.IsEmpty.Should().BeTrue(); - buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.Add("one").Should().Be(BatchBufferAddStatus.AddedFirst); buffer.IsEmpty.Should().BeFalse(); - buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.AddedLast); + buffer.Add("two").Should().Be(BatchBufferAddStatus.AddedLast); buffer.IsEmpty.Should().BeFalse(); - buffer.Add("three").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.Add("three").Should().Be(BatchBufferAddStatus.IgnoredCapacityExceeded); buffer.IsEmpty.Should().BeFalse(); - buffer.Add("four").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.Add("four").Should().Be(BatchBufferAddStatus.IgnoredCapacityExceeded); buffer.IsEmpty.Should().BeFalse(); } @@ -94,16 +99,16 @@ public void Add_CapacityThree_CanAddThrice() buffer.Capacity.Should().Be(3); buffer.IsEmpty.Should().BeTrue(); - buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.Add("one").Should().Be(BatchBufferAddStatus.AddedFirst); buffer.IsEmpty.Should().BeFalse(); - buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.Added); + buffer.Add("two").Should().Be(BatchBufferAddStatus.Added); buffer.IsEmpty.Should().BeFalse(); - buffer.Add("three").Should().Be(StructuredLogBatchBufferAddStatus.AddedLast); + buffer.Add("three").Should().Be(BatchBufferAddStatus.AddedLast); buffer.IsEmpty.Should().BeFalse(); - buffer.Add("four").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredCapacityExceeded); + buffer.Add("four").Should().Be(BatchBufferAddStatus.IgnoredCapacityExceeded); buffer.IsEmpty.Should().BeFalse(); } @@ -115,12 +120,12 @@ public void Add_Flushing_CannotAdd() var flushScope = buffer.TryEnterFlushScope(); - buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredIsFlushing); + buffer.Add("one").Should().Be(BatchBufferAddStatus.IgnoredIsFlushing); buffer.IsEmpty.Should().BeTrue(); flushScope.Dispose(); - buffer.Add("two").Should().Be(StructuredLogBatchBufferAddStatus.AddedFirst); + buffer.Add("two").Should().Be(BatchBufferAddStatus.AddedFirst); buffer.IsEmpty.Should().BeFalse(); } @@ -132,7 +137,7 @@ public void Add_Disposed_CannotAdd() buffer.Dispose(); - buffer.Add("one").Should().Be(StructuredLogBatchBufferAddStatus.IgnoredIsDisposed); + buffer.Add("one").Should().Be(BatchBufferAddStatus.IgnoredIsDisposed); buffer.IsEmpty.Should().BeTrue(); } @@ -311,7 +316,7 @@ public void OnIntervalElapsed_Disposed_DoesNotInvokeCallback() } // cannot use xUnit's Throws() nor Fluent Assertions' ThrowExactly() because the FlushScope is a ref struct - private static void AssertFlushThrows(StructuredLogBatchBuffer.FlushScope flushScope) + private static void AssertFlushThrows(BatchBuffer.FlushScope flushScope) where T : Exception { Exception? exception = null; @@ -329,9 +334,9 @@ private static void AssertFlushThrows(StructuredLogBatchBuffer.FlushScope flu } } -file static class StructuredLogBatchBufferHelpers +file static class BatchBufferHelpers { - public static StructuredLogBatchBufferAddStatus Add(this StructuredLogBatchBuffer buffer, string item) + public static BatchBufferAddStatus Add(this BatchBuffer buffer, string item) { SentryLog log = new(DateTimeOffset.MinValue, SentryId.Empty, SentryLogLevel.Trace, item); return buffer.Add(log); diff --git a/test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs b/test/Sentry.Tests/Internals/BatchProcessorTests.cs similarity index 90% rename from test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs rename to test/Sentry.Tests/Internals/BatchProcessorTests.cs index 12279f6115..665097a96a 100644 --- a/test/Sentry.Tests/Internals/StructuredLogBatchProcessorTests.cs +++ b/test/Sentry.Tests/Internals/BatchProcessorTests.cs @@ -2,7 +2,12 @@ namespace Sentry.Tests.Internals; -public class StructuredLogBatchProcessorTests : IDisposable +/// +/// (formerly "Sentry.Internal.StructuredLogBatchProcessor") was originally developed as Batch Processor for Logs only. +/// When adding support for Trace-connected Metrics, which are quite similar to Logs, it has been made generic to support both. +/// These tests are still using and , rather than and . +/// +public class BatchProcessorTests : IDisposable { private sealed class Fixture { @@ -35,9 +40,9 @@ public void DisableHub() _hub.IsEnabled.Returns(false); } - public StructuredLogBatchProcessor GetSut(int batchCount) + public BatchProcessor GetSut(int batchCount) { - return new StructuredLogBatchProcessor(_hub, batchCount, Timeout.InfiniteTimeSpan, ClientReportRecorder, DiagnosticLogger); + return new SentryLogBatchProcessor(_hub, batchCount, Timeout.InfiniteTimeSpan, ClientReportRecorder, DiagnosticLogger); } } @@ -128,7 +133,7 @@ public async Task Enqueue_Concurrency_CaptureEnvelopes() { tasks[i] = Task.Factory.StartNew(static state => { - var (sync, logsPerTask, taskIndex, processor) = ((ManualResetEvent, int, int, StructuredLogBatchProcessor))state!; + var (sync, logsPerTask, taskIndex, processor) = ((ManualResetEvent, int, int, BatchProcessor))state!; sync.WaitOne(5_000); for (var i = 0; i < logsPerTask; i++) { diff --git a/test/Sentry.Tests/MeasurementUnitTests.cs b/test/Sentry.Tests/MeasurementUnitTests.cs index fad3b2ea1f..64fca21525 100644 --- a/test/Sentry.Tests/MeasurementUnitTests.cs +++ b/test/Sentry.Tests/MeasurementUnitTests.cs @@ -7,6 +7,7 @@ public void DefaultEmpty() { MeasurementUnit m = new(); Assert.Equal("", m.ToString()); + Assert.Null(m.ToNullableString()); } [Fact] @@ -21,6 +22,7 @@ public void CanUseNoneUnit() { var m = MeasurementUnit.None; Assert.Equal("none", m.ToString()); + Assert.Equal("none", m.ToNullableString()); } [Fact] @@ -28,6 +30,7 @@ public void CanUseDurationUnits() { MeasurementUnit m = MeasurementUnit.Duration.Second; Assert.Equal("second", m.ToString()); + Assert.Equal("second", m.ToNullableString()); } [Fact] @@ -35,6 +38,7 @@ public void CanUseInformationUnits() { MeasurementUnit m = MeasurementUnit.Information.Byte; Assert.Equal("byte", m.ToString()); + Assert.Equal("byte", m.ToNullableString()); } [Fact] @@ -42,6 +46,7 @@ public void CanUseFractionUnits() { MeasurementUnit m = MeasurementUnit.Fraction.Percent; Assert.Equal("percent", m.ToString()); + Assert.Equal("percent", m.ToNullableString()); } [Fact] @@ -49,6 +54,7 @@ public void CanUseCustomUnits() { var m = MeasurementUnit.Custom("foo"); Assert.Equal("foo", m.ToString()); + Assert.Equal("foo", m.ToNullableString()); } [Fact] diff --git a/test/Sentry.Tests/Protocol/TraceMetricTests.cs b/test/Sentry.Tests/Protocol/TraceMetricTests.cs new file mode 100644 index 0000000000..222bd1dcb6 --- /dev/null +++ b/test/Sentry.Tests/Protocol/TraceMetricTests.cs @@ -0,0 +1,58 @@ +namespace Sentry.Tests.Protocol; + +/// +/// See . +/// See also . +/// +public class TraceMetricTests +{ + private readonly TestOutputDiagnosticLogger _output; + + public TraceMetricTests(ITestOutputHelper output) + { + _output = new TestOutputDiagnosticLogger(output); + } + + [Fact] + public void Type_IsAssignableFrom_ISentryJsonSerializable() + { + var metric = new TraceMetric([]); + + Assert.IsAssignableFrom(metric); + } + + [Fact] + public void Length_One_Single() + { + var metric = new TraceMetric([CreateMetric()]); + + var length = metric.Length; + + Assert.Equal(1, length); + } + + [Fact] + public void Items_One_Single() + { + var metric = new TraceMetric([CreateMetric()]); + + var items = metric.Items; + + Assert.Equal(1, items.Length); + } + + [Fact] + public void WriteTo_Empty_AsJson() + { + var metric = new TraceMetric([]); + + var document = metric.ToJsonDocument(_output); + + Assert.Equal("""{"items":[]}""", document.RootElement.ToString()); + } + + private static SentryMetric CreateMetric() + { + return new SentryMetric(DateTimeOffset.MinValue, SentryId.Empty, SentryMetricType.Counter, "sentry_tests.trace_metric_tests.counter", 1); + } +} diff --git a/test/Sentry.Tests/SentryLogTests.cs b/test/Sentry.Tests/SentryLogTests.cs index fe14705ad1..e6b3ccdcb3 100644 --- a/test/Sentry.Tests/SentryLogTests.cs +++ b/test/Sentry.Tests/SentryLogTests.cs @@ -33,7 +33,7 @@ public void Protocol_Default_VerifyAttributes() var sdk = new SdkVersion { Name = "Sentry.Test.SDK", - Version = "1.2.3-test+Sentry" + Version = "1.2.3-test+Sentry", }; var log = new SentryLog(Timestamp, TraceId, (SentryLogLevel)24, "message") @@ -406,58 +406,6 @@ public void WriteTo_Attributes_AsJson() entry => entry.Message.Should().Match("*null*is not supported*ignored*") ); } - - [Fact] - public void GetTraceIdAndSpanId_WithActiveSpan_HasBothTraceIdAndSpanId() - { - // Arrange - var span = Substitute.For(); - span.TraceId.Returns(SentryId.Create()); - span.SpanId.Returns(Sentry.SpanId.Create()); - - var hub = Substitute.For(); - hub.GetSpan().Returns(span); - - // Act - SentryLog.GetTraceIdAndSpanId(hub, out var traceId, out var spanId); - - // Assert - traceId.Should().Be(span.TraceId); - spanId.Should().Be(span.SpanId); - } - - [Fact] - public void GetTraceIdAndSpanId_WithoutActiveSpan_HasOnlyTraceIdButNoSpanId() - { - // Arrange - var hub = Substitute.For(); - hub.GetSpan().Returns((ISpan)null); - - var scope = new Scope(); - hub.SubstituteConfigureScope(scope); - - // Act - SentryLog.GetTraceIdAndSpanId(hub, out var traceId, out var spanId); - - // Assert - traceId.Should().Be(scope.PropagationContext.TraceId); - spanId.Should().BeNull(); - } - - [Fact] - public void GetTraceIdAndSpanId_WithoutIds_ShouldBeUnreachable() - { - // Arrange - var hub = Substitute.For(); - hub.GetSpan().Returns((ISpan)null); - - // Act - SentryLog.GetTraceIdAndSpanId(hub, out var traceId, out var spanId); - - // Assert - traceId.Should().Be(SentryId.Empty); - spanId.Should().BeNull(); - } } file static class AssertExtensions diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.Options.cs b/test/Sentry.Tests/SentryMetricEmitterTests.Options.cs new file mode 100644 index 0000000000..e6c5d10132 --- /dev/null +++ b/test/Sentry.Tests/SentryMetricEmitterTests.Options.cs @@ -0,0 +1,38 @@ +#nullable enable + +namespace Sentry.Tests; + +public partial class SentryMetricEmitterTests +{ + [Fact] + public void EnableMetrics_Default_True() + { + var options = new SentryOptions(); + + options.Experimental.EnableMetrics.Should().BeTrue(); + } + + [Fact] + public void BeforeSendMetric_Default_Null() + { + var options = new SentryOptions(); + + options.Experimental.BeforeSendMetricInternal.Should().BeNull(); + } + + [Fact] + public void BeforeSendMetric_Set_NotNull() + { + _fixture.Options.Experimental.SetBeforeSendMetric(static (SentryMetric metric) => metric); + + _fixture.Options.Experimental.BeforeSendMetricInternal.Should().NotBeNull(); + } + + [Fact] + public void BeforeSendMetric_SetNull_Null() + { + _fixture.Options.Experimental.SetBeforeSendMetric(null!); + + _fixture.Options.Experimental.BeforeSendMetricInternal.Should().BeNull(); + } +} diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.Types.cs b/test/Sentry.Tests/SentryMetricEmitterTests.Types.cs new file mode 100644 index 0000000000..38b2d40ff0 --- /dev/null +++ b/test/Sentry.Tests/SentryMetricEmitterTests.Types.cs @@ -0,0 +1,427 @@ +#nullable enable + +namespace Sentry.Tests; + +public partial class SentryMetricEmitterTests +{ + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Enabled_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1, []); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Disabled_DoesNotCaptureEnvelope(SentryMetricType type) + { + _fixture.Options.Experimental.EnableMetrics = false; + var metrics = _fixture.GetSut(); + + metrics.Emit(type, 1, []); + metrics.Flush(); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Attributes_Enabled_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1, [new KeyValuePair("attribute-key", "attribute-value")]); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelope(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Attributes_Disabled_DoesNotCaptureEnvelope(SentryMetricType type) + { + _fixture.Options.Experimental.EnableMetrics = false; + var metrics = _fixture.GetSut(); + + metrics.Emit(type, 1, [new KeyValuePair("attribute-key", "attribute-value")]); + metrics.Flush(); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Byte_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1, []); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Int16_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1, []); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Int32_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1, []); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Int64_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1L, []); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Single_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1f, []); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Double_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1d, []); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Decimal_DoesNotCaptureEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + metrics.Emit(type, 1m, []); + metrics.Flush(); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Warning); + entry.Message.Should().Be("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double."); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEquivalentTo([typeof(decimal)]); + } + +#if NET5_0_OR_GREATER + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Half_DoesNotCaptureEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + metrics.Emit(type, Half.One, []); + metrics.Flush(); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Warning); + entry.Message.Should().Be("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double."); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEquivalentTo([typeof(Half)]); + } +#endif + + [Theory] + [InlineData(SentryMetricType.Counter)] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Enum_DoesNotCaptureEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + metrics.Emit(type, (StringComparison)1, []); + metrics.Flush(); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Warning); + entry.Message.Should().Be("{0} is unsupported type for Sentry Metrics. The only supported types are byte, short, int, long, float, and double."); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEquivalentTo([typeof(StringComparison)]); + } + + [Theory] + [InlineData(SentryMetricType.Counter, nameof(SentryMetricType.Counter), typeof(int))] + [InlineData(SentryMetricType.Gauge, nameof(SentryMetricType.Gauge), typeof(int))] + [InlineData(SentryMetricType.Distribution, nameof(SentryMetricType.Distribution), typeof(int))] + public void Emit_Name_Null_DoesNotCaptureEnvelope(SentryMetricType type, string arg0, Type arg1) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + metrics.Emit(type, null!, 1); + metrics.Flush(); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Warning); + entry.Message.Should().Be("Name of metrics cannot be null or empty. Metric-Type: {0}; Value-Type: {1}"); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEquivalentTo([arg0, arg1]); + } + + [Theory] + [InlineData(SentryMetricType.Counter, nameof(SentryMetricType.Counter), typeof(int))] + [InlineData(SentryMetricType.Gauge, nameof(SentryMetricType.Gauge), typeof(int))] + [InlineData(SentryMetricType.Distribution, nameof(SentryMetricType.Distribution), typeof(int))] + public void Emit_Name_Empty_DoesNotCaptureEnvelope(SentryMetricType type, string arg0, Type arg1) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + metrics.Emit(type, "", 1); + metrics.Flush(); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Warning); + entry.Message.Should().Be("Name of metrics cannot be null or empty. Metric-Type: {0}; Value-Type: {1}"); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEquivalentTo([arg0, arg1]); + } + + [Fact] + public void Type_EmitMethods_StringUnitParameterOverloadsAreObsoleteForForwardCompatibility() + { + var type = typeof(SentryMetricEmitter); + + type.Methods() + .Where(method => method.IsPublic && method.ReturnType == typeof(void) && method.IsGenericMethod && method.Name.StartsWith("Emit")) + .Should().NotBeEmpty().And.AllSatisfy(method => + { + var unitParameter = method.GetParameters().SingleOrDefault(parameter => parameter.Name == "unit"); + + if (unitParameter is null || unitParameter.ParameterType == typeof(MeasurementUnit)) + { + method.GetCustomAttribute().Should().BeNull("because Method '{0}' does not take a 'unit' as a 'string'", method); + } + else + { + unitParameter.ParameterType.Should().Be(typeof(string)); + + var obsolete = method.GetCustomAttribute(); + obsolete.Should().NotBeNull("because Method '{0}' does take a 'unit' as a 'string'", method); + obsolete.Message.Should().Be(SentryMetricEmitter.ObsoleteStringUnitForwardCompatibility); + obsolete.IsError.Should().BeFalse(); + } + }); + } + + [Theory] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Unit_String_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1, "measurement_unit"); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } + + [Theory] + [InlineData(SentryMetricType.Gauge)] + [InlineData(SentryMetricType.Distribution)] + public void Emit_Unit_MeasurementUnit_CapturesEnvelope(SentryMetricType type) + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Emit(type, 1, MeasurementUnit.Custom("measurement_unit")); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelopeWithoutAttributes(envelope, type); + } +} + +[Obsolete(SentryMetricEmitter.ObsoleteStringUnitForwardCompatibility)] +file static class SentryMetricEmitterExtensions +{ + public static void Emit(this SentryMetricEmitter metrics, SentryMetricType type, T value, ReadOnlySpan> attributes) where T : struct + { + switch (type) + { + case SentryMetricType.Counter: + metrics.EmitCounter("sentry_tests.sentry_trace_metrics_tests.counter", value, attributes); + break; + case SentryMetricType.Gauge: + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.counter", value, "measurement_unit", attributes); + break; + case SentryMetricType.Distribution: + metrics.EmitDistribution("sentry_tests.sentry_trace_metrics_tests.counter", value, "measurement_unit", attributes); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + } + + public static void Emit(this SentryMetricEmitter metrics, SentryMetricType type, string name, T value) where T : struct + { + switch (type) + { + case SentryMetricType.Counter: + metrics.EmitCounter(name, value); + break; + case SentryMetricType.Gauge: + metrics.EmitGauge(name, value, "measurement_unit"); + break; + case SentryMetricType.Distribution: + metrics.EmitDistribution(name, value, "measurement_unit"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + } + + public static void Emit(this SentryMetricEmitter metrics, SentryMetricType type, T value, string? unit) where T : struct + { + switch (type) + { + case SentryMetricType.Counter: + throw new NotSupportedException($"{nameof(SentryMetric<>.Unit)} for {nameof(SentryMetricType.Counter)} is not supported."); + case SentryMetricType.Gauge: + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.counter", value, unit); + break; + case SentryMetricType.Distribution: + metrics.EmitDistribution("sentry_tests.sentry_trace_metrics_tests.counter", value, unit); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + } + + public static void Emit(this SentryMetricEmitter metrics, SentryMetricType type, T value, MeasurementUnit unit) where T : struct + { + switch (type) + { + case SentryMetricType.Counter: + throw new NotSupportedException($"{nameof(SentryMetric<>.Unit)} for {nameof(SentryMetricType.Counter)} is not supported."); + case SentryMetricType.Gauge: + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.counter", value, unit); + break; + case SentryMetricType.Distribution: + metrics.EmitDistribution("sentry_tests.sentry_trace_metrics_tests.counter", value, unit); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + } +} diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.Values.cs b/test/Sentry.Tests/SentryMetricEmitterTests.Values.cs new file mode 100644 index 0000000000..d8a3d251b3 --- /dev/null +++ b/test/Sentry.Tests/SentryMetricEmitterTests.Values.cs @@ -0,0 +1,266 @@ +#nullable enable + +namespace Sentry.Tests; + +public partial class SentryMetricEmitterTests +{ + [Fact] + public void TryGetValue_FromByte_SupportedType() + { + var metric = CreateCounter(1); + + metric.TryGetValue(out var value).Should().BeTrue(); + value.Should().Be(1); + } + + [Fact] + public void TryGetValue_FromInt16_SupportedType() + { + var metric = CreateCounter(1); + + metric.TryGetValue(out var value).Should().BeTrue(); + value.Should().Be(1); + } + + [Fact] + public void TryGetValue_FromInt32_SupportedType() + { + var metric = CreateCounter(1); + + metric.TryGetValue(out var value).Should().BeTrue(); + value.Should().Be(1); + } + + [Fact] + public void TryGetValue_FromInt64_SupportedType() + { + var metric = CreateCounter(1L); + + metric.TryGetValue(out var value).Should().BeTrue(); + value.Should().Be(1L); + } + + [Fact] + public void TryGetValue_FromSingle_SupportedType() + { + var metric = CreateCounter(1f); + + metric.TryGetValue(out var value).Should().BeTrue(); + value.Should().Be(1f); + } + + [Fact] + public void TryGetValue_FromDouble_SupportedType() + { + var metric = CreateCounter(1d); + + metric.TryGetValue(out var value).Should().BeTrue(); + value.Should().Be(1d); + } + + [Fact] + public void TryGetValue_FromDecimal_UnsupportedType() + { + var metric = CreateCounter(1m); + + metric.TryGetValue(out var @byte).Should().BeFalse(); + @byte.Should().Be(0); + metric.TryGetValue(out var @short).Should().BeFalse(); + @short.Should().Be(0); + metric.TryGetValue(out var @int).Should().BeFalse(); + @int.Should().Be(0); + metric.TryGetValue(out var @long).Should().BeFalse(); + @long.Should().Be(0L); + metric.TryGetValue(out var @float).Should().BeFalse(); + @float.Should().Be(0f); + metric.TryGetValue(out var @double).Should().BeFalse(); + @double.Should().Be(0d); + } + + // see: https://develop.sentry.dev/sdk/telemetry/attributes/#units + // see: https://getsentry.github.io/relay/relay_metrics/enum.MetricUnit.html + [Theory] + [InlineData(MeasurementUnit.Duration.Nanosecond, "nanosecond")] + [InlineData(MeasurementUnit.Duration.Microsecond, "microsecond")] + [InlineData(MeasurementUnit.Duration.Millisecond, "millisecond")] + [InlineData(MeasurementUnit.Duration.Second, "second")] + [InlineData(MeasurementUnit.Duration.Minute, "minute")] + [InlineData(MeasurementUnit.Duration.Hour, "hour")] + [InlineData(MeasurementUnit.Duration.Day, "day")] + [InlineData(MeasurementUnit.Duration.Week, "week")] + [InlineData(MeasurementUnit.Information.Bit, "bit")] + [InlineData(MeasurementUnit.Information.Byte, "byte")] + [InlineData(MeasurementUnit.Information.Kilobyte, "kilobyte")] + [InlineData(MeasurementUnit.Information.Kibibyte, "kibibyte")] + [InlineData(MeasurementUnit.Information.Megabyte, "megabyte")] + [InlineData(MeasurementUnit.Information.Mebibyte, "mebibyte")] + [InlineData(MeasurementUnit.Information.Gigabyte, "gigabyte")] + [InlineData(MeasurementUnit.Information.Gibibyte, "gibibyte")] + [InlineData(MeasurementUnit.Information.Terabyte, "terabyte")] + [InlineData(MeasurementUnit.Information.Tebibyte, "tebibyte")] + [InlineData(MeasurementUnit.Information.Petabyte, "petabyte")] + [InlineData(MeasurementUnit.Information.Pebibyte, "pebibyte")] + [InlineData(MeasurementUnit.Information.Exabyte, "exabyte")] + [InlineData(MeasurementUnit.Information.Exbibyte, "exbibyte")] + [InlineData(MeasurementUnit.Fraction.Ratio, "ratio")] + [InlineData(MeasurementUnit.Fraction.Percent, "percent")] + public void Emit_Unit_MeasurementUnit_Predefined(MeasurementUnit unit, string expected) + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.gauge", 1, unit); + + captured.Should().NotBeNull(); + captured.Unit.Should().Be(expected); + } + + [Fact] + public void Emit_Unit_MeasurementUnit_None() + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.gauge", 1, MeasurementUnit.None); + + captured.Should().NotBeNull(); + captured.Unit.Should().Be("none"); + } + + [Fact] + public void Emit_Unit_MeasurementUnit_Custom() + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.gauge", 1, MeasurementUnit.Custom("custom_unit")); + + captured.Should().NotBeNull(); + captured.Unit.Should().Be("custom_unit"); + } + + [Fact] + public void Emit_Unit_MeasurementUnit_Empty() + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.gauge", 1, MeasurementUnit.Custom("")); + + captured.Should().NotBeNull(); + captured.Unit.Should().BeEmpty(); + } + + [Fact] + public void Emit_Unit_MeasurementUnit_Null() + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.gauge", 1, MeasurementUnit.Parse(null)); + + captured.Should().NotBeNull(); + captured.Unit.Should().BeNull(); + } + + [Fact] + public void Emit_Unit_MeasurementUnit_Default() + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitGauge("sentry_tests.sentry_trace_metrics_tests.gauge", 1, default(MeasurementUnit)); + + captured.Should().NotBeNull(); + captured.Unit.Should().BeNull(); + } + + [Fact] + [Obsolete(SentryMetricEmitter.ObsoleteStringUnitForwardCompatibility)] + public void Emit_Unit_String_Custom() + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitDistribution("sentry_tests.sentry_trace_metrics_tests.distribution", 1, "custom_unit"); + + captured.Should().NotBeNull(); + captured.Unit.Should().Be("custom_unit"); + } + + [Fact] + [Obsolete(SentryMetricEmitter.ObsoleteStringUnitForwardCompatibility)] + public void Emit_Unit_String_Empty() + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitDistribution("sentry_tests.sentry_trace_metrics_tests.distribution", 1, ""); + + captured.Should().NotBeNull(); + captured.Unit.Should().BeEmpty(); + } + + [Fact] + [Obsolete(SentryMetricEmitter.ObsoleteStringUnitForwardCompatibility)] + public void Emit_Unit_String_Null() + { + SentryMetric? captured = null; + _fixture.Options.Experimental.SetBeforeSendMetric(SentryMetric? (SentryMetric metric) => + { + captured = metric; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitDistribution("sentry_tests.sentry_trace_metrics_tests.distribution", 1, (string?)null); + + captured.Should().NotBeNull(); + captured.Unit.Should().BeNull(); + } + + private static SentryMetric CreateCounter(T value) where T : struct + { + return new SentryMetric(DateTimeOffset.MinValue, SentryId.Empty, SentryMetricType.Counter, "sentry_tests.sentry_trace_metrics_tests.counter", value); + } +} diff --git a/test/Sentry.Tests/SentryMetricEmitterTests.cs b/test/Sentry.Tests/SentryMetricEmitterTests.cs new file mode 100644 index 0000000000..c3a47eebda --- /dev/null +++ b/test/Sentry.Tests/SentryMetricEmitterTests.cs @@ -0,0 +1,280 @@ +#nullable enable + +namespace Sentry.Tests; + +/// +/// +/// +public partial class SentryMetricEmitterTests : IDisposable +{ + internal sealed class Fixture + { + public Fixture() + { + DiagnosticLogger = new InMemoryDiagnosticLogger(); + Hub = Substitute.For(); + Options = new SentryOptions + { + Debug = true, + DiagnosticLogger = DiagnosticLogger, + }; + Clock = new MockClock(new DateTimeOffset(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2))); + BatchSize = 2; + BatchTimeout = Timeout.InfiniteTimeSpan; + TraceId = SentryId.Create(); + SpanId = Sentry.SpanId.Create(); + + Hub.IsEnabled.Returns(true); + + var span = Substitute.For(); + span.TraceId.Returns(TraceId); + span.SpanId.Returns(SpanId.Value); + Hub.GetSpan().Returns(span); + + ExpectedAttributes = new Dictionary(1) + { + { "attribute-key", "attribute-value" }, + }; + } + + public InMemoryDiagnosticLogger DiagnosticLogger { get; } + public IHub Hub { get; } + public SentryOptions Options { get; } + public ISystemClock Clock { get; } + public int BatchSize { get; set; } + public TimeSpan BatchTimeout { get; set; } + public SentryId TraceId { get; private set; } + public SpanId? SpanId { get; private set; } + + public Dictionary ExpectedAttributes { get; } + + public void WithoutActiveSpan() + { + Hub.GetSpan().Returns((ISpan?)null); + + var scope = new Scope(); + Hub.SubstituteConfigureScope(scope); + TraceId = scope.PropagationContext.TraceId; + SpanId = null; + } + + public SentryMetricEmitter GetSut() => SentryMetricEmitter.Create(Hub, Options, Clock, BatchSize, BatchTimeout); + } + + private readonly Fixture _fixture; + + public SentryMetricEmitterTests() + { + _fixture = new Fixture(); + } + + public void Dispose() + { + _fixture.DiagnosticLogger.Entries.Should().BeEmpty(); + } + + [Fact] + public void Create_Enabled_NewDefaultInstance() + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + + var instance = _fixture.GetSut(); + var other = _fixture.GetSut(); + + instance.Should().BeOfType(); + instance.Should().NotBeSameAs(other); + } + + [Fact] + public void Create_Disabled_CachedDisabledInstance() + { + _fixture.Options.Experimental.EnableMetrics = false; + + var instance = _fixture.GetSut(); + var other = _fixture.GetSut(); + + instance.Should().BeOfType(); + instance.Should().BeSameAs(other); + } + + [Fact] + public void Emit_WithoutActiveSpan_CapturesEnvelope() + { + _fixture.WithoutActiveSpan(); + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.EmitCounter("sentry_tests.sentry_trace_metrics_tests.counter", 1, [new KeyValuePair("attribute-key", "attribute-value")]); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelope(envelope, SentryMetricType.Counter); + } + + [Fact] + public void Emit_WithBeforeSendMetric_InvokesCallback() + { + var invocations = 0; + SentryMetric configuredMetric = null!; + + Assert.True(_fixture.Options.Experimental.EnableMetrics); + _fixture.Options.Experimental.SetBeforeSendMetric((SentryMetric metric) => + { + invocations++; + configuredMetric = metric; + return metric; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitCounter("sentry_tests.sentry_trace_metrics_tests.counter", 1, [new KeyValuePair("attribute-key", "attribute-value")]); + metrics.Flush(); + + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + invocations.Should().Be(1); + _fixture.AssertMetric(configuredMetric, SentryMetricType.Counter); + } + + [Fact] + public void Emit_WhenBeforeSendMetricReturnsNull_DoesNotCaptureEnvelope() + { + var invocations = 0; + + Assert.True(_fixture.Options.Experimental.EnableMetrics); + _fixture.Options.Experimental.SetBeforeSendMetric((SentryMetric metric) => + { + invocations++; + return null; + }); + var metrics = _fixture.GetSut(); + + metrics.EmitCounter("sentry_tests.sentry_trace_metrics_tests.counter", 1, [new KeyValuePair("attribute-key", "attribute-value")]); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + invocations.Should().Be(1); + } + + [Fact] + public void Emit_InvalidBeforeSendMetric_DoesNotCaptureEnvelope() + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + _fixture.Options.Experimental.SetBeforeSendMetric(static (SentryMetric metric) => throw new InvalidOperationException()); + var metrics = _fixture.GetSut(); + + metrics.EmitCounter("sentry_tests.sentry_trace_metrics_tests.counter", 1); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Error); + entry.Message.Should().Be("The BeforeSendMetric callback threw an exception. The Metric will be dropped."); + entry.Exception.Should().BeOfType(); + entry.Args.Should().BeEmpty(); + } + + [Fact] + public void Flush_AfterEmit_CapturesEnvelope() + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + Envelope envelope = null!; + _fixture.Hub.CaptureEnvelope(Arg.Do(arg => envelope = arg)); + + metrics.Flush(); + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + envelope.Should().BeNull(); + + metrics.EmitCounter("sentry_tests.sentry_trace_metrics_tests.counter", 1, [new KeyValuePair("attribute-key", "attribute-value")]); + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + envelope.Should().BeNull(); + + metrics.Flush(); + _fixture.Hub.Received(1).CaptureEnvelope(Arg.Any()); + _fixture.AssertEnvelope(envelope, SentryMetricType.Counter); + } + + [Fact] + public void Dispose_BeforeEmit_DoesNotCaptureEnvelope() + { + Assert.True(_fixture.Options.Experimental.EnableMetrics); + var metrics = _fixture.GetSut(); + + var defaultMetrics = metrics.Should().BeOfType().Which; + defaultMetrics.Dispose(); + metrics.EmitCounter("sentry_tests.sentry_trace_metrics_tests.counter", 1, [new KeyValuePair("attribute-key", "attribute-value")]); + + _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); + var entry = _fixture.DiagnosticLogger.Dequeue(); + entry.Level.Should().Be(SentryLevel.Info); + entry.Message.Should().Be("{0}-Buffer full ... dropping {0}"); + entry.Exception.Should().BeNull(); + entry.Args.Should().BeEquivalentTo([typeof(SentryMetric).Name]); + } +} + +internal static class MetricsAssertionExtensions +{ + public static void AssertEnvelope(this SentryMetricEmitterTests.Fixture fixture, Envelope envelope, SentryMetricType type) where T : struct + { + envelope.Header.Should().ContainSingle().Which.Key.Should().Be("sdk"); + var item = envelope.Items.Should().ContainSingle().Which; + + var metric = item.Payload.Should().BeOfType().Which.Source.Should().BeOfType().Which; + AssertMetric(fixture, metric, type); + + Assert.Collection(item.Header, + element => Assert.Equal(CreateHeader("type", "trace_metric"), element), + element => Assert.Equal(CreateHeader("item_count", 1), element), + element => Assert.Equal(CreateHeader("content_type", "application/vnd.sentry.items.trace-metric+json"), element)); + } + + public static void AssertEnvelopeWithoutAttributes(this SentryMetricEmitterTests.Fixture fixture, Envelope envelope, SentryMetricType type) where T : struct + { + fixture.ExpectedAttributes.Clear(); + AssertEnvelope(fixture, envelope, type); + } + + public static void AssertMetric(this SentryMetricEmitterTests.Fixture fixture, TraceMetric metric, SentryMetricType type) where T : struct + { + var items = metric.Items; + items.Length.Should().Be(1); + var cast = items[0] as SentryMetric; + Assert.NotNull(cast); + AssertMetric(fixture, cast, type); + } + + public static void AssertMetric(this SentryMetricEmitterTests.Fixture fixture, SentryMetric metric, SentryMetricType type) where T : struct + { + metric.Should().BeOfType>(); + metric.Timestamp.Should().Be(fixture.Clock.GetUtcNow()); + metric.TraceId.Should().Be(fixture.TraceId); + metric.Type.Should().Be(type); + metric.Name.Should().Be("sentry_tests.sentry_trace_metrics_tests.counter"); + metric.Value.Should().BeOfType().And.Be(1); + metric.SpanId.Should().Be(fixture.SpanId); + if (metric.Type is SentryMetricType.Gauge or SentryMetricType.Distribution) + { + metric.Unit.Should().Be("measurement_unit"); + } + else + { + metric.Unit.Should().BeNull(); + } + + metric.TryGetValue(out var match).Should().BeTrue(); + match.Should().NotBe(default(T)); + + foreach (var expectedAttribute in fixture.ExpectedAttributes) + { + metric.TryGetAttribute(expectedAttribute.Key, out string? value).Should().BeTrue(); + value.Should().Be(expectedAttribute.Value); + } + } + + private static KeyValuePair CreateHeader(string name, object? value) + { + return new KeyValuePair(name, value); + } +} diff --git a/test/Sentry.Tests/SentryMetricTests.cs b/test/Sentry.Tests/SentryMetricTests.cs new file mode 100644 index 0000000000..2614c99989 --- /dev/null +++ b/test/Sentry.Tests/SentryMetricTests.cs @@ -0,0 +1,490 @@ +using System.Text.Encodings.Web; +using Sentry.PlatformAbstractions; + +namespace Sentry.Tests; + +/// +/// See . +/// See also . +/// +public class SentryMetricTests +{ + private static readonly DateTimeOffset Timestamp = new(2025, 04, 22, 14, 51, 00, 789, TimeSpan.FromHours(2)); + private static readonly SentryId TraceId = SentryId.Create(); + private static readonly SpanId? SpanId = Sentry.SpanId.Create(); + + private static readonly ISystemClock Clock = new MockClock(Timestamp); + + private readonly TestOutputDiagnosticLogger _output; + + public SentryMetricTests(ITestOutputHelper output) + { + _output = new TestOutputDiagnosticLogger(output); + } + + [Fact] + public void Protocol_Default_VerifyAttributes() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + var sdk = new SdkVersion + { + Name = "Sentry.Test.SDK", + Version = "1.2.3-test+Sentry", + }; + + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1) + { + SpanId = SpanId, + Unit = "test_unit", + }; + metric.SetAttribute("attribute", "value"); + metric.SetDefaultAttributes(options, sdk); + + metric.Timestamp.Should().Be(Timestamp); + metric.TraceId.Should().Be(TraceId); + metric.Type.Should().Be(SentryMetricType.Counter); + metric.Name.Should().Be("sentry_tests.sentry_metric_tests.counter"); + metric.Value.Should().Be(1); + metric.SpanId.Should().Be(SpanId); + metric.Unit.Should().BeEquivalentTo("test_unit"); + + metric.TryGetAttribute("attribute", out var attribute).Should().BeTrue(); + attribute.Should().Be("value"); + metric.TryGetAttribute("sentry.environment", out var environment).Should().BeTrue(); + environment.Should().Be(options.Environment); + metric.TryGetAttribute("sentry.release", out var release).Should().BeTrue(); + release.Should().Be(options.Release); + metric.TryGetAttribute("sentry.sdk.name", out var name).Should().BeTrue(); + name.Should().Be(sdk.Name); + metric.TryGetAttribute("sentry.sdk.version", out var version).Should().BeTrue(); + version.Should().Be(sdk.Version); + metric.TryGetAttribute("not-found", out var notFound).Should().BeFalse(); + notFound.Should().BeNull(); + } + + [Fact] + public void WriteTo_Envelope_MinimalSerializedSentryMetric() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + metric.SetDefaultAttributes(options, new SdkVersion()); + + var envelope = Envelope.FromMetric(new TraceMetric([metric])); + + using var stream = new MemoryStream(); + envelope.Serialize(stream, _output, Clock); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var header = JsonDocument.Parse(reader.ReadLine()!); + var item = JsonDocument.Parse(reader.ReadLine()!); + var payload = JsonDocument.Parse(reader.ReadLine()!); + + reader.EndOfStream.Should().BeTrue(); + + header.ToIndentedJsonString().Should().Be($$""" + { + "sdk": { + "name": "{{SdkVersion.Instance.Name}}", + "version": "{{SdkVersion.Instance.Version}}" + }, + "sent_at": "{{Timestamp.Format()}}" + } + """); + + item.ToIndentedJsonString().Should().Match(""" + { + "type": "trace_metric", + "item_count": 1, + "content_type": "application/vnd.sentry.items.trace-metric+json", + "length": ?* + } + """); + + payload.ToIndentedJsonString().Should().Be($$""" + { + "items": [ + { + "timestamp": {{Timestamp.GetTimestamp()}}, + "type": "counter", + "name": "sentry_tests.sentry_metric_tests.counter", + "value": 1, + "trace_id": "{{TraceId.ToString()}}", + "attributes": { + "sentry.environment": { + "value": "my-environment", + "type": "string" + }, + "sentry.release": { + "value": "my-release", + "type": "string" + } + } + } + ] + } + """); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_EnvelopeItem_MaximalSerializedSentryMetric() + { + var options = new SentryOptions + { + Environment = "my-environment", + Release = "my-release", + }; + + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1) + { + SpanId = SpanId, + Unit = "test_unit", + }; + metric.SetAttribute("string-attribute", "string-value"); + metric.SetAttribute("boolean-attribute", true); + metric.SetAttribute("integer-attribute", 3); + metric.SetAttribute("double-attribute", 4.4); + metric.SetDefaultAttributes(options, new SdkVersion { Name = "Sentry.Test.SDK", Version = "1.2.3-test+Sentry" }); + + var envelope = EnvelopeItem.FromMetric(new TraceMetric([metric])); + + using var stream = new MemoryStream(); + envelope.Serialize(stream, _output); + stream.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + var item = JsonDocument.Parse(reader.ReadLine()!); + var payload = JsonDocument.Parse(reader.ReadLine()!); + + reader.EndOfStream.Should().BeTrue(); + + item.ToIndentedJsonString().Should().Match(""" + { + "type": "trace_metric", + "item_count": 1, + "content_type": "application/vnd.sentry.items.trace-metric+json", + "length": ?* + } + """); + + payload.ToIndentedJsonString().Should().Be($$""" + { + "items": [ + { + "timestamp": {{Timestamp.GetTimestamp()}}, + "type": "counter", + "name": "sentry_tests.sentry_metric_tests.counter", + "value": 1, + "trace_id": "{{TraceId.ToString()}}", + "span_id": "{{SpanId.ToString()}}", + "unit": "test_unit", + "attributes": { + "string-attribute": { + "value": "string-value", + "type": "string" + }, + "boolean-attribute": { + "value": true, + "type": "boolean" + }, + "integer-attribute": { + "value": 3, + "type": "integer" + }, + "double-attribute": { + "value": {{4.4.Format()}}, + "type": "double" + }, + "sentry.environment": { + "value": "my-environment", + "type": "string" + }, + "sentry.release": { + "value": "my-release", + "type": "string" + }, + "sentry.sdk.name": { + "value": "Sentry.Test.SDK", + "type": "string" + }, + "sentry.sdk.version": { + "value": "1.2.3-test+Sentry", + "type": "string" + } + } + } + ] + } + """); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_NumericValueType_Byte() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + + var document = metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var value = document.RootElement.GetProperty("value"); + + value.ValueKind.Should().Be(JsonValueKind.Number); + value.TryGetByte(out var @byte).Should().BeTrue(); + @byte.Should().Be(1); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_NumericValueType_Int16() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + + var document = metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var value = document.RootElement.GetProperty("value"); + + value.ValueKind.Should().Be(JsonValueKind.Number); + value.TryGetInt16(out var @short).Should().BeTrue(); + @short.Should().Be(1); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_NumericValueType_Int32() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + + var document = metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var value = document.RootElement.GetProperty("value"); + + value.ValueKind.Should().Be(JsonValueKind.Number); + value.TryGetInt32(out var @int).Should().BeTrue(); + @int.Should().Be(1); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_NumericValueType_Int64() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + + var document = metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var value = document.RootElement.GetProperty("value"); + + value.ValueKind.Should().Be(JsonValueKind.Number); + value.TryGetInt64(out var @long).Should().BeTrue(); + @long.Should().Be(1L); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_NumericValueType_Single() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + + var document = metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var value = document.RootElement.GetProperty("value"); + + value.ValueKind.Should().Be(JsonValueKind.Number); + value.TryGetSingle(out var @float).Should().BeTrue(); + @float.Should().Be(1f); + + _output.Entries.Should().BeEmpty(); + } + + [Fact] + public void WriteTo_NumericValueType_Double() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + + var document = metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var value = document.RootElement.GetProperty("value"); + + value.ValueKind.Should().Be(JsonValueKind.Number); + value.TryGetDouble(out var @double).Should().BeTrue(); + @double.Should().Be(1d); + + _output.Entries.Should().BeEmpty(); + } + +#if DEBUG && (NET || NETCOREAPP) + [Fact] + public void WriteTo_NumericValueType_Decimal() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + + var exception = Assert.ThrowsAny(() => metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output)); + exception.Message.Should().Contain($"Unhandled Metric Type {typeof(decimal)}."); + exception.Message.Should().Contain("This instruction should be unreachable."); + + _output.Entries.Should().BeEmpty(); + } +#endif + + [Fact] + public void WriteTo_Attributes_AsJson() + { + var metric = new SentryMetric(Timestamp, TraceId, SentryMetricType.Counter, "sentry_tests.sentry_metric_tests.counter", 1); + metric.SetAttribute("sbyte", sbyte.MinValue); + metric.SetAttribute("byte", byte.MaxValue); + metric.SetAttribute("short", short.MinValue); + metric.SetAttribute("ushort", ushort.MaxValue); + metric.SetAttribute("int", int.MinValue); + metric.SetAttribute("uint", uint.MaxValue); + metric.SetAttribute("long", long.MinValue); + metric.SetAttribute("ulong", ulong.MaxValue); +#if NET5_0_OR_GREATER + metric.SetAttribute("nint", nint.MinValue); + metric.SetAttribute("nuint", nuint.MaxValue); +#endif + metric.SetAttribute("float", 1f); + metric.SetAttribute("double", 2d); + metric.SetAttribute("decimal", 3m); + metric.SetAttribute("bool", true); + metric.SetAttribute("char", 'c'); + metric.SetAttribute("string", "string"); +#if (NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) + metric.SetAttribute("object", KeyValuePair.Create("key", "value")); +#else + metric.SetAttribute("object", new KeyValuePair("key", "value")); +#endif + metric.SetAttribute("null", null!); + + var document = metric.ToJsonDocument(static (obj, writer, logger) => obj.WriteTo(writer, logger), _output); + var attributes = document.RootElement.GetProperty("attributes"); + Assert.Collection(attributes.EnumerateObject().ToArray(), + property => property.AssertAttributeInteger("sbyte", json => json.GetSByte(), sbyte.MinValue), + property => property.AssertAttributeInteger("byte", json => json.GetByte(), byte.MaxValue), + property => property.AssertAttributeInteger("short", json => json.GetInt16(), short.MinValue), + property => property.AssertAttributeInteger("ushort", json => json.GetUInt16(), ushort.MaxValue), + property => property.AssertAttributeInteger("int", json => json.GetInt32(), int.MinValue), + property => property.AssertAttributeInteger("uint", json => json.GetUInt32(), uint.MaxValue), + property => property.AssertAttributeInteger("long", json => json.GetInt64(), long.MinValue), + property => property.AssertAttributeString("ulong", json => json.GetString(), ulong.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#if NET5_0_OR_GREATER + property => property.AssertAttributeInteger("nint", json => json.GetInt64(), nint.MinValue), + property => property.AssertAttributeString("nuint", json => json.GetString(), nuint.MaxValue.ToString(NumberFormatInfo.InvariantInfo)), +#endif + property => property.AssertAttributeDouble("float", json => json.GetSingle(), 1f), + property => property.AssertAttributeDouble("double", json => json.GetDouble(), 2d), + property => property.AssertAttributeString("decimal", json => json.GetString(), 3m.ToString(NumberFormatInfo.InvariantInfo)), + property => property.AssertAttributeBoolean("bool", json => json.GetBoolean(), true), + property => property.AssertAttributeString("char", json => json.GetString(), "c"), + property => property.AssertAttributeString("string", json => json.GetString(), "string"), + property => property.AssertAttributeString("object", json => json.GetString(), "[key, value]") + ); + Assert.Collection(_output.Entries, + entry => entry.Message.Should().Match("*ulong*is not supported*overflow*"), +#if NET5_0_OR_GREATER + entry => entry.Message.Should().Match("*nuint*is not supported*64-bit*"), +#endif + entry => entry.Message.Should().Match("*decimal*is not supported*overflow*"), + entry => entry.Message.Should().Match("*System.Collections.Generic.KeyValuePair`2[System.String,System.String]*is not supported*ToString*"), + entry => entry.Message.Should().Match("*null*is not supported*ignored*") + ); + } +} + +file static class AssertExtensions +{ + public static void AssertAttributeString(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "string", getValue, value); + } + + public static void AssertAttributeBoolean(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "boolean", getValue, value); + } + + public static void AssertAttributeInteger(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "integer", getValue, value); + } + + public static void AssertAttributeDouble(this JsonProperty attribute, string name, Func getValue, T value) + { + attribute.AssertAttribute(name, "double", getValue, value); + } + + private static void AssertAttribute(this JsonProperty attribute, string name, string type, Func getValue, T value) + { + Assert.Equal(name, attribute.Name); + Assert.Collection(attribute.Value.EnumerateObject().ToArray(), + property => + { + Assert.Equal("value", property.Name); + Assert.Equal(value, getValue(property.Value)); + }, property => + { + Assert.Equal("type", property.Name); + Assert.Equal(type, property.Value.GetString()); + }); + } +} + +file static class DateTimeOffsetExtensions +{ + public static string GetTimestamp(this DateTimeOffset value) + { + var timestamp = value.ToUnixTimeMilliseconds() / 1_000.0; + return timestamp.ToString(NumberFormatInfo.InvariantInfo); + } +} + +file static class JsonFormatterExtensions +{ + public static string Format(this DateTimeOffset value) + { + return value.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); + } + + public static string Format(this double value) + { + if (SentryRuntime.Current.IsNetFx() || SentryRuntime.Current.IsMono()) + { + // since .NET Core 3.0, the Floating-Point Formatter returns the shortest roundtrippable string, rather than the exact string + // e.g. on .NET Framework (Windows) + // * 2.2.ToString() -> 2.2000000000000002 + // * 4.4.ToString() -> 4.4000000000000004 + // see https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/ + + var utf16Text = value.ToString("G17", NumberFormatInfo.InvariantInfo); + var utf8Bytes = Encoding.UTF8.GetBytes(utf16Text); + return Encoding.UTF8.GetString(utf8Bytes); + } + + return value.ToString(NumberFormatInfo.InvariantInfo); + } +} + +file static class JsonDocumentExtensions +{ + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private static readonly JsonSerializerOptions Options = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, + }; + + public static string ToIndentedJsonString(this JsonDocument document) + { + var json = JsonSerializer.Serialize(document, Options); + + // Standardize on \n on all platforms, for consistency in tests. + return IsWindows ? json.Replace("\r\n", "\n") : json; + } +} diff --git a/test/Sentry.Tests/SentryMetricTypeTests.cs b/test/Sentry.Tests/SentryMetricTypeTests.cs new file mode 100644 index 0000000000..cb35155342 --- /dev/null +++ b/test/Sentry.Tests/SentryMetricTypeTests.cs @@ -0,0 +1,55 @@ +namespace Sentry.Tests; + +/// +/// +/// +public class SentryMetricTypeTests +{ + private readonly InMemoryDiagnosticLogger _logger; + + public SentryMetricTypeTests() + { + _logger = new InMemoryDiagnosticLogger(); + } + + [Theory] + [InlineData(SentryMetricType.Counter, "counter")] + [InlineData(SentryMetricType.Gauge, "gauge")] + [InlineData(SentryMetricType.Distribution, "distribution")] + public void Protocol_WithinRange_Valid(SentryMetricType type, string expected) + { +#if NET5_0_OR_GREATER + Assert.True(Enum.IsDefined(type)); +#else + Assert.True(Enum.IsDefined(typeof(SentryMetricType), type)); +#endif + + var actual = type.ToProtocolString(_logger); + + Assert.Equal(expected, actual); + Assert.Empty(_logger.Entries); + } + + [Theory] + [InlineData(-1)] + [InlineData(3)] + public void Protocol_OutOfRange_Invalid(int value) + { + var type = (SentryMetricType)value; +#if NET5_0_OR_GREATER + Assert.False(Enum.IsDefined(type)); +#else + Assert.False(Enum.IsDefined(typeof(SentryMetricType), type)); +#endif + + var actual = type.ToProtocolString(_logger); + + Assert.Equal("unknown", actual); + var entry = Assert.Single(_logger.Entries); + Assert.Multiple( + () => Assert.Equal(SentryLevel.Debug, entry.Level), + () => Assert.Equal("Metric type {0} is not defined.", entry.Message), + () => Assert.Null(entry.Exception), + () => Assert.Equal([type], entry.Args)); + } +} diff --git a/test/Sentry.Tests/SentryStructuredLoggerTests.cs b/test/Sentry.Tests/SentryStructuredLoggerTests.cs index 008a56df19..9a2def8c9f 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -240,9 +240,9 @@ public void Dispose_BeforeLog_DoesNotCaptureEnvelope() _fixture.Hub.Received(0).CaptureEnvelope(Arg.Any()); var entry = _fixture.DiagnosticLogger.Dequeue(); entry.Level.Should().Be(SentryLevel.Info); - entry.Message.Should().Be("Log Buffer full ... dropping log"); + entry.Message.Should().Be("{0}-Buffer full ... dropping {0}"); entry.Exception.Should().BeNull(); - entry.Args.Should().BeEmpty(); + entry.Args.Should().BeEquivalentTo([nameof(SentryLog)]); } private static void ConfigureLog(SentryLog log) @@ -251,7 +251,7 @@ private static void ConfigureLog(SentryLog log) } } -internal static class AssertionExtensions +internal static class LoggerAssertionExtensions { public static void AssertEnvelope(this SentryStructuredLoggerTests.Fixture fixture, Envelope envelope, SentryLogLevel level) {