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
}