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
}