diff --git a/test/Sentry.Tests/Extensibility/DisabledHubTests.cs b/test/Sentry.Tests/Extensibility/DisabledHubTests.cs index e03f8a82a3..084c5e220b 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..2a11f29bea 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 InMemorySentryTraceMetrics(); + 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/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 17c3474705..395544c947 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/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..0901898680 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") diff --git a/test/Sentry.Tests/SentryMetricTests.cs b/test/Sentry.Tests/SentryMetricTests.cs new file mode 100644 index 0000000000..ecfaf81056 --- /dev/null +++ b/test/Sentry.Tests/SentryMetricTests.cs @@ -0,0 +1,542 @@ +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*") + ); + } + + [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 + SentryMetric.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 + SentryMetric.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 + SentryMetric.GetTraceIdAndSpanId(hub, out var traceId, out var spanId); + + // Assert + traceId.Should().Be(SentryId.Empty); + spanId.Should().BeNull(); + } +} + +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 3a038fd840..9a2def8c9f 100644 --- a/test/Sentry.Tests/SentryStructuredLoggerTests.cs +++ b/test/Sentry.Tests/SentryStructuredLoggerTests.cs @@ -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) { diff --git a/test/Sentry.Tests/SentryTraceMetricsTests.Options.cs b/test/Sentry.Tests/SentryTraceMetricsTests.Options.cs new file mode 100644 index 0000000000..ce3e533c02 --- /dev/null +++ b/test/Sentry.Tests/SentryTraceMetricsTests.Options.cs @@ -0,0 +1,169 @@ +#nullable enable + +namespace Sentry.Tests; + +public partial class SentryTraceMetricsTests +{ + [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(Callback.Nop); + + _fixture.Options.Experimental.BeforeSendMetricInternal.Should().NotBeNull(); + } + + [Fact] + public void BeforeSendMetric_SetByte_InvokeDelegate() + { + _fixture.Options.Experimental.SetBeforeSendMetric(static metric => + { + metric.SetAttribute(nameof(Byte), nameof(Byte)); + return metric; + }); + + var metric = CreateCounter(1); + _fixture.Options.Experimental.BeforeSendMetricInternal!.Invoke(metric); + + metric.TryGetAttribute(nameof(Byte), out var value).Should().BeTrue(); + value.Should().Be(nameof(Byte)); + } + + [Fact] + public void BeforeSendMetric_SetInt16_InvokeDelegate() + { + _fixture.Options.Experimental.SetBeforeSendMetric(static metric => + { + metric.SetAttribute(nameof(Int16), nameof(Int16)); + return metric; + }); + + var metric = CreateCounter(1); + _fixture.Options.Experimental.BeforeSendMetricInternal!.Invoke(metric); + + metric.TryGetAttribute(nameof(Int16), out var value).Should().BeTrue(); + value.Should().Be(nameof(Int16)); + } + + [Fact] + public void BeforeSendMetric_SetInt32_InvokeDelegate() + { + _fixture.Options.Experimental.SetBeforeSendMetric(static metric => + { + metric.SetAttribute(nameof(Int32), nameof(Int32)); + return metric; + }); + + var metric = CreateCounter(1); + _fixture.Options.Experimental.BeforeSendMetricInternal!.Invoke(metric); + + metric.TryGetAttribute(nameof(Int32), out var value).Should().BeTrue(); + value.Should().Be(nameof(Int32)); + } + + [Fact] + public void BeforeSendMetric_SetInt64_InvokeDelegate() + { + _fixture.Options.Experimental.SetBeforeSendMetric(static metric => + { + metric.SetAttribute(nameof(Int64), nameof(Int64)); + return metric; + }); + + var metric = CreateCounter(1L); + _fixture.Options.Experimental.BeforeSendMetricInternal!.Invoke(metric); + + metric.TryGetAttribute(nameof(Int64), out var value).Should().BeTrue(); + value.Should().Be(nameof(Int64)); + } + + [Fact] + public void BeforeSendMetric_SetSingle_InvokeDelegate() + { + _fixture.Options.Experimental.SetBeforeSendMetric(static metric => + { + metric.SetAttribute(nameof(Single), nameof(Single)); + return metric; + }); + + var metric = CreateCounter(1f); + _fixture.Options.Experimental.BeforeSendMetricInternal!.Invoke(metric); + + metric.TryGetAttribute(nameof(Single), out var value).Should().BeTrue(); + value.Should().Be(nameof(Single)); + } + + [Fact] + public void BeforeSendMetric_SetDouble_InvokeDelegate() + { + _fixture.Options.Experimental.SetBeforeSendMetric(static metric => + { + metric.SetAttribute(nameof(Double), nameof(Double)); + return metric; + }); + + var metric = CreateCounter(1d); + _fixture.Options.Experimental.BeforeSendMetricInternal!.Invoke(metric); + + metric.TryGetAttribute(nameof(Double), out var value).Should().BeTrue(); + value.Should().Be(nameof(Double)); + } + + [Fact] + public void BeforeSendMetric_SetDecimal_UnsupportedType() + { + _fixture.Options.Experimental.SetBeforeSendMetric(static metric => + { + metric.SetAttribute(nameof(Decimal), nameof(Decimal)); + return metric; + }); + + _fixture.Options.Experimental.BeforeSendMetricInternal.Should().NotBeNull(); + + 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)]); + } + + [Fact] + public void BeforeSendMetric_SetNull_NoOp() + { + _fixture.Options.Experimental.SetBeforeSendMetric(null!); + + var metric = CreateCounter(1); + _fixture.Options.Experimental.BeforeSendMetricInternal!.Invoke(metric); + + metric.TryGetAttribute(nameof(Int32), out var value).Should().BeFalse(); + value.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); + } +} + +file static class Callback where T : struct +{ + internal static SentryMetric? Nop(SentryMetric metric) + { + return metric; + } +} diff --git a/test/Sentry.Tests/SentryTraceMetricsTests.Types.cs b/test/Sentry.Tests/SentryTraceMetricsTests.Types.cs new file mode 100644 index 0000000000..432c27b254 --- /dev/null +++ b/test/Sentry.Tests/SentryTraceMetricsTests.Types.cs @@ -0,0 +1,329 @@ +#nullable enable + +namespace Sentry.Tests; + +public partial class SentryTraceMetricsTests +{ + [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]); + } +} + +file static class SentryTraceMetricsExtensions +{ + public static void Emit(this SentryTraceMetrics 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 SentryTraceMetrics 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); + } + } +} diff --git a/test/Sentry.Tests/SentryTraceMetricsTests.cs b/test/Sentry.Tests/SentryTraceMetricsTests.cs new file mode 100644 index 0000000000..18496a629d --- /dev/null +++ b/test/Sentry.Tests/SentryTraceMetricsTests.cs @@ -0,0 +1,276 @@ +#nullable enable + +namespace Sentry.Tests; + +/// +/// +/// +public partial class SentryTraceMetricsTests : 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 SentryTraceMetrics GetSut() => SentryTraceMetrics.Create(Hub, Options, Clock, BatchSize, BatchTimeout); + } + + private readonly Fixture _fixture; + + public SentryTraceMetricsTests() + { + _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 SentryTraceMetricsTests.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 SentryTraceMetricsTests.Fixture fixture, Envelope envelope, SentryMetricType type) where T : struct + { + fixture.ExpectedAttributes.Clear(); + AssertEnvelope(fixture, envelope, type); + } + + public static void AssertMetric(this SentryTraceMetricsTests.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 SentryTraceMetricsTests.Fixture fixture, SentryMetric metric, SentryMetricType type) where T : struct + { + 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().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(); + } + + 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); + } +}