diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6e31ab46..f56c59256f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - 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)) +- Outbound HTTP requests now show in the Network tab for Android Session Replays ([#4860](https://github.com/getsentry/sentry-dotnet/pull/4860)) ### Fixes diff --git a/samples/Sentry.Samples.Maui/Properties/launchSettings.json b/samples/Sentry.Samples.Maui/Properties/launchSettings.json index bc20df0c32..f7ce884b65 100644 --- a/samples/Sentry.Samples.Maui/Properties/launchSettings.json +++ b/samples/Sentry.Samples.Maui/Properties/launchSettings.json @@ -1,5 +1,8 @@ { "profiles": { + "Android": { + "commandName": "Project" + }, "Windows Machine": { "commandName": "MsixPackage", "nativeDebugging": false diff --git a/samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj b/samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj index fe4b910261..54a7a914dc 100644 --- a/samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj +++ b/samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj @@ -69,12 +69,10 @@ $([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) - android-arm64 ios-arm64 iossimulator-arm64 maccatalyst-arm64 - android-x64 iossimulator-x64 maccatalyst-x64 diff --git a/src/Sentry.Bindings.Android/Transforms/Metadata.xml b/src/Sentry.Bindings.Android/Transforms/Metadata.xml index dc9b56bee4..5b458c6451 100644 --- a/src/Sentry.Bindings.Android/Transforms/Metadata.xml +++ b/src/Sentry.Bindings.Android/Transforms/Metadata.xml @@ -24,6 +24,7 @@ Sentry.JavaSdk.Android.Core.Internal.ThreadDump Sentry.JavaSdk.Android.Core.Internal.Util Sentry.JavaSdk.Android.Ndk + Sentry.JavaSdk.Android.Replay Sentry.JavaSdk.Android.Supplemental Sentry.JavaSdk.Cache diff --git a/src/Sentry/Platforms/Android/DotnetReplayBreadcrumbConverter.cs b/src/Sentry/Platforms/Android/DotnetReplayBreadcrumbConverter.cs new file mode 100644 index 0000000000..27cab84a2f --- /dev/null +++ b/src/Sentry/Platforms/Android/DotnetReplayBreadcrumbConverter.cs @@ -0,0 +1,60 @@ +using Sentry.JavaSdk; +using Sentry.JavaSdk.Android.Replay; + +namespace Sentry.Android; + +internal class DotnetReplayBreadcrumbConverter(Sentry.JavaSdk.SentryOptions options) + : DefaultReplayBreadcrumbConverter(options), IReplayBreadcrumbConverter +{ + private const string HttpCategory = "http"; + + public override global::IO.Sentry.Rrweb.RRWebEvent? Convert(Sentry.JavaSdk.Breadcrumb breadcrumb) + { + // The Java SDK automatically converts breadcrumbs for outgoing http requests into performance spans + // that show in the Network tab of session replays... however, it expects certain data to be stored in a + // specific format in the breadcrumb.data. It needs values for httpStartTimestamp and httpEndTimestamp + // stored as Double or Long representations of timestamps (milliseconds since epoch). + // .NET breadcrumb data is always stored as strings, so we have to convert these to numeric values here. + try + { + if (breadcrumb is { Category: HttpCategory, Data: { } data }) + { + NormalizeTimestampField(data, SentryHttpMessageHandler.HttpStartTimestampKey); + NormalizeTimestampField(data, SentryHttpMessageHandler.HttpEndTimestampKey); + } + } + catch + { + // Best-effort: never fail conversion because of parsing issues... we may be parsing breadcrumbs that don't + // originate from the .NET SDK. + } + + return base.Convert(breadcrumb); + } + + private static void NormalizeTimestampField(IDictionary data, string key) + { + if (!data.TryGetValue(key, out var value) || value is Java.Lang.Number) + { + return; + } + + var str = (value as Java.Lang.String)?.ToString() ?? value.ToString(); + if (string.IsNullOrWhiteSpace(str)) + { + return; + } + + if (long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var asLong)) + { + data[key] = Java.Lang.Long.ValueOf(asLong); + return; + } + + if (double.TryParse(str, NumberStyles.Float, CultureInfo.InvariantCulture, out var asDouble)) + { + // Preserve type as Double; Java converter divides by 1000.0. + data[key] = Java.Lang.Double.ValueOf(asDouble); + } + } +} diff --git a/src/Sentry/Platforms/Android/SentrySdk.cs b/src/Sentry/Platforms/Android/SentrySdk.cs index 6b92b601ec..919ec00357 100644 --- a/src/Sentry/Platforms/Android/SentrySdk.cs +++ b/src/Sentry/Platforms/Android/SentrySdk.cs @@ -144,6 +144,10 @@ private static void InitSentryAndroidSdk(SentryOptions options) (JavaDouble?)options.Native.ExperimentalOptions.SessionReplay.SessionSampleRate; o.SessionReplay.SetMaskAllImages(options.Native.ExperimentalOptions.SessionReplay.MaskAllImages); o.SessionReplay.SetMaskAllText(options.Native.ExperimentalOptions.SessionReplay.MaskAllText); + if (o.ReplayController is { } replayController) + { + replayController.BreadcrumbConverter = new DotnetReplayBreadcrumbConverter(o); + } // These options are intentionally set and not exposed for modification o.EnableExternalConfiguration = false; diff --git a/src/Sentry/SentryHttpMessageHandler.cs b/src/Sentry/SentryHttpMessageHandler.cs index f75f958b8a..6f8489690d 100644 --- a/src/Sentry/SentryHttpMessageHandler.cs +++ b/src/Sentry/SentryHttpMessageHandler.cs @@ -15,6 +15,8 @@ public class SentryHttpMessageHandler : SentryMessageHandler private readonly ISentryFailedRequestHandler? _failedRequestHandler; internal const string HttpClientOrigin = "auto.http.client"; + internal const string HttpStartTimestampKey = "http.start_timestamp"; + internal const string HttpEndTimestampKey = "http.end_timestamp"; /// /// Constructs an instance of . @@ -89,6 +91,15 @@ protected internal override void HandleResponse(HttpResponseMessage response, IS {"method", method}, {"status_code", ((int) response.StatusCode).ToString()} }; +#if ANDROID + if (span is not null) + { + // Ensure the breadcrumb can be converted to RRWeb so that it shows up in the network tab in Session Replay. + // See https://github.com/getsentry/sentry-java/blob/94bff8dc0a952ad8c1b6815a9eda5005e41b92c7/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt#L195-L199 + breadcrumbData[HttpStartTimestampKey] = span.StartTimestamp.ToUnixTimeMilliseconds().ToString("F0", CultureInfo.InvariantCulture); + breadcrumbData[HttpEndTimestampKey] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString("F0", CultureInfo.InvariantCulture); + } +#endif _hub.AddBreadcrumb(string.Empty, "http", "http", breadcrumbData); // Create events for failed requests diff --git a/test/Sentry.Tests/Platforms/Android/DotnetReplayBreadcrumbConverterTests.cs b/test/Sentry.Tests/Platforms/Android/DotnetReplayBreadcrumbConverterTests.cs new file mode 100644 index 0000000000..d92c083dd5 --- /dev/null +++ b/test/Sentry.Tests/Platforms/Android/DotnetReplayBreadcrumbConverterTests.cs @@ -0,0 +1,38 @@ +#if ANDROID +using Sentry.Android; + +namespace Sentry.Tests.Platforms.Android; + +public class DotnetReplayBreadcrumbConverterTests +{ + [Fact] + public void Convert_HttpBreadcrumbWithStringTimestamps_ConvertsToNumeric() + { + // Arrange + var options = new Sentry.JavaSdk.SentryOptions(); + var converter = new DotnetReplayBreadcrumbConverter(options); + var breadcrumb = new Sentry.JavaSdk.Breadcrumb + { + Category = "http", + Data = + { + { "url", "https://example.com" }, + { SentryHttpMessageHandler.HttpStartTimestampKey, "1625079600000" }, + { SentryHttpMessageHandler.HttpEndTimestampKey, "1625079660000" } + } + }; + + // Act + var rrwebEvent = converter.Convert(breadcrumb); + + // Assert + rrwebEvent.Should().BeOfType(); + var rrWebSpanEvent = rrwebEvent as IO.Sentry.Rrweb.RRWebSpanEvent; + Assert.NotNull(rrWebSpanEvent); + // Note the converter divides by 1000 to get ms. + // See https://github.com/getsentry/sentry-java/blob/94bff8dc0a952ad8c1b6815a9eda5005e41b92c7/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt#L215-L228 + rrWebSpanEvent.StartTimestamp.Should().Be(1625079600L); + rrWebSpanEvent.EndTimestamp.Should().Be(1625079660L); + } +} +#endif diff --git a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs index 584fcd43d7..f21d2a2237 100644 --- a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -631,5 +631,77 @@ public void Send_Executed_FailedRequestsCaptured() // Assert failedRequestHandler.Received(1).HandleResponse(Arg.Any()); } + +#endif + +#if ANDROID + [Fact] + public void HandleResponse_SpanExists_AddsReplayBreadcrumbData() + { + // Arrange + var scope = new Scope(); + var hub = Substitute.For(); + hub.SubstituteConfigureScope(scope); + + var options = new SentryOptions + { + CaptureFailedRequests = false + }; + + var sut = new SentryHttpMessageHandler(hub, options); + + var method = "GET"; + var url = "https://localhost/"; + var response = new HttpResponseMessage(HttpStatusCode.OK); + + var span = Substitute.For(); + span.StartTimestamp.Returns(DateTimeOffset.UtcNow.AddMilliseconds(-50)); + + // Act + sut.HandleResponse(response, span, method, url); + + // Assert + var breadcrumb = scope.Breadcrumbs.First(); + breadcrumb.Type.Should().Be("http"); + breadcrumb.Category.Should().Be("http"); + + breadcrumb.Data.Should().NotBeNull(); + breadcrumb.Data!.Should().ContainKey(SentryHttpMessageHandler.HttpStartTimestampKey); + breadcrumb.Data.Should().ContainKey(SentryHttpMessageHandler.HttpEndTimestampKey); + + long.TryParse(breadcrumb.Data![SentryHttpMessageHandler.HttpStartTimestampKey], NumberStyles.Integer, CultureInfo.InvariantCulture, out var startMs) + .Should().BeTrue(); + long.TryParse(breadcrumb.Data![SentryHttpMessageHandler.HttpEndTimestampKey], NumberStyles.Integer, CultureInfo.InvariantCulture, out var endMs) + .Should().BeTrue(); + + startMs.Should().BeGreaterThan(0); + startMs.Should().Be(span.StartTimestamp.ToUnixTimeMilliseconds()); + endMs.Should().BeGreaterThan(0); + endMs.Should().BeGreaterOrEqualTo(startMs); + } + + [Fact] + public void HandleResponse_NoSpanExists_NoReplayBreadcrumbData() + { + // Arrange + var scope = new Scope(); + var hub = Substitute.For(); + hub.SubstituteConfigureScope(scope); + + var sut = new SentryHttpMessageHandler(hub, null); + + var method = "GET"; + var url = "https://localhost/"; + var response = new HttpResponseMessage(HttpStatusCode.OK); + + // Act + sut.HandleResponse(response, span: null, method, url); + + // Assert + var breadcrumb = scope.Breadcrumbs.First(); + breadcrumb.Data.Should().NotBeNull(); + breadcrumb.Data!.Should().NotContainKey(SentryHttpMessageHandler.HttpStartTimestampKey); + breadcrumb.Data.Should().NotContainKey(SentryHttpMessageHandler.HttpEndTimestampKey); + } #endif }