Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions samples/Sentry.Samples.Maui/MainPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Extensions.Logging;

namespace Sentry.Samples.Maui;
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 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<string, Java.Lang.Object> 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);
}
}
}
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
31 changes: 31 additions & 0 deletions src/Sentry/Platforms/Cocoa/Extensions/CocoaExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,37 @@ public static NSDictionary<NSString, NSString> ToNSDictionaryStrings(
this IReadOnlyCollection<KeyValuePair<string, TValue>> dict) =>
dict.Count == 0 ? null : dict.ToNSDictionary();

public static NSDictionary<NSString, NSObject>? ToCocoaBreadcrumbData(
this IReadOnlyDictionary<string, string> source)
{
// Avoid an allocation if we can
if (source.Count == 0)
{
return null;
}

var dict = new NSDictionary<NSString, NSObject>();

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);
}

/// <summary>
/// Converts an <see cref="NSNumber"/> to a .NET primitive data type and returns the result box in an <see cref="object"/>.
/// </summary>
Expand Down
16 changes: 16 additions & 0 deletions src/Sentry/SentryHttpMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>
/// Constructs an instance of <see cref="SentryHttpMessageHandler"/>.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IO.Sentry.Rrweb.RRWebSpanEvent>();
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
75 changes: 67 additions & 8 deletions test/Sentry.Tests/SentryHttpMessageHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IHub>();
var failedRequestHandler = Substitute.For<ISentryFailedRequestHandler>();
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<ISpan>();
span.StartTimestamp.Returns(DateTimeOffset.UtcNow.AddMilliseconds(-50));

// Act
client.Get(url);
sut.HandleResponse(response, span, method, url);

// Assert
failedRequestHandler.Received(1).HandleResponse(Arg.Any<HttpResponseMessage>());
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<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);
breadcrumb.Data.Should().NotContainKey(SentryHttpMessageHandler.RequestStartKey);
}
#endif
}
Loading