Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0289e93
Initial commit
jamescrosswell Jan 22, 2026
588beb3
.
jamescrosswell Jan 22, 2026
31fd5a0
Format code
getsentry-bot Jan 22, 2026
37254e4
Allow building against local instance of Sentry Java SDK
jamescrosswell Jan 22, 2026
79e9225
Fixed local maven references
jamescrosswell Jan 26, 2026
cabaa59
Fix issues building against local maven repository
jamescrosswell Jan 26, 2026
4cdf36c
Remove unecessary assignment of default converter
jamescrosswell Jan 27, 2026
d05cde3
Format code
getsentry-bot Jan 27, 2026
0b6c66c
Adding sample code temporarily
jamescrosswell Jan 27, 2026
96e9e26
Added custom breadcrumb converter to deal with the conversion between…
jamescrosswell Jan 28, 2026
c3a3889
Remove unecessary changes refactored to other PRs
jamescrosswell Jan 28, 2026
32b1b26
Remove changes refactored to #4873
jamescrosswell Jan 28, 2026
a40a7a0
Consolidate constants
jamescrosswell Jan 28, 2026
85d47f7
message handler tests
jamescrosswell Jan 28, 2026
e51db44
fixed test
jamescrosswell Jan 28, 2026
2c847fe
Added DotnetReplayBreadcrumbConverterTests
jamescrosswell Jan 29, 2026
6da450a
changelog
jamescrosswell Jan 29, 2026
e7b460b
Merge remote-tracking branch 'origin/main' into replay-network-spans
jamescrosswell Feb 1, 2026
4abdbec
Remove unintended change to samples
jamescrosswell Feb 5, 2026
d631418
Merge remote-tracking branch 'origin/main' into replay-network-spans
jamescrosswell Feb 5, 2026
0f2f114
Fix sample app so it can be run in Rider
jamescrosswell Feb 6, 2026
550a5c4
Review feedback
jamescrosswell Feb 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions samples/Sentry.Samples.Maui/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"profiles": {
"Android": {
"commandName": "Project"
},
"Windows Machine": {
"commandName": "MsixPackage",
"nativeDebugging": false
Expand Down
2 changes: 0 additions & 2 deletions samples/Sentry.Samples.Maui/Sentry.Samples.Maui.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,10 @@
<OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)</OSArchitecture>
<TargetPlatformIdentifier>$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)'))</TargetPlatformIdentifier>

<RuntimeIdentifier Condition="'$(TargetPlatformIdentifier)' == 'android' And '$(OSArchitecture)' == 'Arm64'">android-arm64</RuntimeIdentifier>
<RuntimeIdentifier Condition="'$(TargetPlatformIdentifier)' == 'ios' And '$(_IsPublishing)' == 'true'">ios-arm64</RuntimeIdentifier>
<RuntimeIdentifier Condition="'$(TargetPlatformIdentifier)' == 'ios' And '$(OSArchitecture)' == 'Arm64' And '$(_IsPublishing)' != 'true'">iossimulator-arm64</RuntimeIdentifier>
<RuntimeIdentifier Condition="'$(TargetPlatformIdentifier)' == 'maccatalyst' And '$(OSArchitecture)' == 'Arm64'">maccatalyst-arm64</RuntimeIdentifier>

<RuntimeIdentifier Condition="'$(TargetPlatformIdentifier)' == 'android' And '$(OSArchitecture)' == 'x64'">android-x64</RuntimeIdentifier>
<RuntimeIdentifier Condition="'$(TargetPlatformIdentifier)' == 'ios' And '$(OSArchitecture)' == 'x64' And '$(_IsPublishing)' != 'true'">iossimulator-x64</RuntimeIdentifier>
<RuntimeIdentifier Condition="'$(TargetPlatformIdentifier)' == 'maccatalyst' And '$(OSArchitecture)' == 'x64'">maccatalyst-x64</RuntimeIdentifier>
</PropertyGroup>
Expand Down
1 change: 1 addition & 0 deletions src/Sentry.Bindings.Android/Transforms/Metadata.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<attr path="/api/package[@name='io.sentry.android.core.internal.threaddump']" name="managedName">Sentry.JavaSdk.Android.Core.Internal.ThreadDump</attr>
<attr path="/api/package[@name='io.sentry.android.core.internal.util']" name="managedName">Sentry.JavaSdk.Android.Core.Internal.Util</attr>
<attr path="/api/package[@name='io.sentry.android.ndk']" name="managedName">Sentry.JavaSdk.Android.Ndk</attr>
<attr path="/api/package[@name='io.sentry.android.replay']" name="managedName">Sentry.JavaSdk.Android.Replay</attr>
<attr path="/api/package[@name='io.sentry.android.supplemental']" name="managedName">Sentry.JavaSdk.Android.Supplemental</attr>
<attr path="/api/package[@name='io.sentry.cache']" name="managedName">Sentry.JavaSdk.Cache</attr>
<!-- Renaming 'clientreport' to 'clientreports' (plural) as a workaround for typename matching namespace: io.sentry.clientreport.clientreport -->
Expand Down
60 changes: 60 additions & 0 deletions src/Sentry/Platforms/Android/DotnetReplayBreadcrumbConverter.cs
Original file line number Diff line number Diff line change
@@ -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<string, Java.Lang.Object> 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);
}
}
}
4 changes: 4 additions & 0 deletions src/Sentry/Platforms/Android/SentrySdk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/Sentry/SentryHttpMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>
/// Constructs an instance of <see cref="SentryHttpMessageHandler"/>.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IO.Sentry.Rrweb.RRWebSpanEvent>();
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
72 changes: 72 additions & 0 deletions test/Sentry.Tests/SentryHttpMessageHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -631,5 +631,77 @@ public void Send_Executed_FailedRequestsCaptured()
// Assert
failedRequestHandler.Received(1).HandleResponse(Arg.Any<HttpResponseMessage>());
}

#endif

#if ANDROID
[Fact]
public void HandleResponse_SpanExists_AddsReplayBreadcrumbData()
{
// Arrange
var scope = new Scope();
var hub = Substitute.For<IHub>();
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<ISpan>();
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<IHub>();
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
}
Loading