diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa9a6b673..0aa749d010 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/MainPage.xaml.cs b/samples/Sentry.Samples.Maui/MainPage.xaml.cs index b82a13787d..9ea62da935 100644 --- a/samples/Sentry.Samples.Maui/MainPage.xaml.cs +++ b/samples/Sentry.Samples.Maui/MainPage.xaml.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.Extensions.Logging; namespace Sentry.Samples.Maui; 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..da9ca72c88 --- /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 converter expects httpStartTimestamp/httpEndTimestamp to be Double or Long. + // .NET breadcrumb data is always stored as strings. We convert these to numeric here so that the base.Convert() + // method doesn't throw an exception. + 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) + { + data.TryGetValue(key, out var value); + if (value is null or Java.Lang.Long or Java.Lang.Double or Java.Lang.Integer or Java.Lang.Float) + { + return; + } + + // Note: `data.Get` returns `Java.Lang.Object`, not a .NET `string`. + 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/Platforms/Cocoa/Extensions/CocoaExtensions.cs b/src/Sentry/Platforms/Cocoa/Extensions/CocoaExtensions.cs index cf6776a450..05ae6911be 100644 --- a/src/Sentry/Platforms/Cocoa/Extensions/CocoaExtensions.cs +++ b/src/Sentry/Platforms/Cocoa/Extensions/CocoaExtensions.cs @@ -195,6 +195,37 @@ public static NSDictionary ToNSDictionaryStrings( this IReadOnlyCollection> dict) => dict.Count == 0 ? null : dict.ToNSDictionary(); + public static NSDictionary? ToCocoaBreadcrumbData( + this IReadOnlyDictionary source) + { + // Avoid an allocation if we can + if (source.Count == 0) + { + return null; + } + + var dict = new NSDictionary(); + + foreach (var (key, value) in source) + { + // Cocoa Session Replay expects `request_start` to be a Date (`NSDate`). + // See https://github.com/getsentry/sentry-cocoa/blob/2b4e787e55558e1475eda8f98b02c19a0d511741/Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift#L73 + if (key == SentryHttpMessageHandler.RequestStartKey && TryParseUnixMs(value, out var unixMs)) + { + var dto = DateTimeOffset.FromUnixTimeMilliseconds(unixMs); + dict[key] = dto.ToNSDate(); + continue; + } + + dict[key] = NSObject.FromObject(value); + } + + return dict.Count == 0 ? null : dict; + + static bool TryParseUnixMs(string value, out long unixMs) => + long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out unixMs); + } + /// /// Converts an to a .NET primitive data type and returns the result box in an . /// diff --git a/src/Sentry/SentryHttpMessageHandler.cs b/src/Sentry/SentryHttpMessageHandler.cs index f75f958b8a..c2aca78cdf 100644 --- a/src/Sentry/SentryHttpMessageHandler.cs +++ b/src/Sentry/SentryHttpMessageHandler.cs @@ -15,6 +15,9 @@ 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"; + internal const string RequestStartKey = "request_start"; /// /// Constructs an instance of . @@ -89,6 +92,19 @@ protected internal override void HandleResponse(HttpResponseMessage response, IS {"method", method}, {"status_code", ((int) response.StatusCode).ToString()} }; + if (span is not null) + { +#if ANDROID + // 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); +#elif IOS || MACCATALYST + // 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-cocoa/blob/2b4e787e55558e1475eda8f98b02c19a0d511741/Sources/Swift/Integrations/SessionReplay/SentrySRDefaultBreadcrumbConverter.swift#L70-L86 + breadcrumbData[RequestStartKey] = span.StartTimestamp.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..fdd1f9c4df --- /dev/null +++ b/test/Sentry.Tests/Platforms/Android/DotnetReplayBreadcrumbConverterTests.cs @@ -0,0 +1,37 @@ +#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 + 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..14009a6549 100644 --- a/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpMessageHandlerTests.cs @@ -611,25 +611,84 @@ public void Send_Executed_BreadcrumbCreated() Assert.True(breadcrumbGenerated.Data.ContainsKey(statusKey)); Assert.Equal(expectedBreadcrumbData[statusKey], breadcrumbGenerated.Data[statusKey]); } +#endif +#if ANDROID || IOS || MACCATALYST [Fact] - public void Send_Executed_FailedRequestsCaptured() + public void HandleResponse_SpanExists_AddsReplayBreadcrumbData() { // Arrange + var scope = new Scope(); var hub = Substitute.For(); - var failedRequestHandler = Substitute.For(); - var options = new SentryOptions(); + 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); - using var innerHandler = new FakeHttpMessageHandler(); - using var sentryHandler = new SentryHttpMessageHandler(hub, options, innerHandler, failedRequestHandler); - using var client = new HttpClient(sentryHandler); + var span = Substitute.For(); + span.StartTimestamp.Returns(DateTimeOffset.UtcNow.AddMilliseconds(-50)); // Act - client.Get(url); + sut.HandleResponse(response, span, method, url); // Assert - failedRequestHandler.Received(1).HandleResponse(Arg.Any()); + var breadcrumb = scope.Breadcrumbs.First(); + breadcrumb.Type.Should().Be("http"); + breadcrumb.Category.Should().Be("http"); + + breadcrumb.Data.Should().NotBeNull(); +#if ANDROID + 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); +#elif IOS || MACCATALYST + breadcrumb.Data!.Should().ContainKey(SentryHttpMessageHandler.RequestStartKey); + long.TryParse(breadcrumb.Data![SentryHttpMessageHandler.RequestStartKey], NumberStyles.Integer, CultureInfo.InvariantCulture, out var startMs) + .Should().BeTrue(); + startMs.Should().BeGreaterThan(0); + startMs.Should().Be(span.StartTimestamp.ToUnixTimeMilliseconds()); +#endif + } + + [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); + breadcrumb.Data.Should().NotContainKey(SentryHttpMessageHandler.RequestStartKey); } #endif }