Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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 @@ -13,6 +13,7 @@
- Remove `IDisposable` from `SentryStructuredLogger`. Disposal is intended through the owning `IHub` instance ([#4424](https://github.com/getsentry/sentry-dotnet/pull/4424))
- Ensure all buffered logs are sent to Sentry when the application terminates unexpectedly ([#4425](https://github.com/getsentry/sentry-dotnet/pull/4425))
- `InvalidOperationException` potentially thrown during a race condition, especially in concurrent high-volume logging scenarios ([#4428](https://github.com/getsentry/sentry-dotnet/pull/4428))
- Only detects custom session replay masks when necessary to avoid performance issues in MAUI apps with complex UIs ([#4445](https://github.com/getsentry/sentry-dotnet/pull/4445))

### Dependencies

Expand Down
7 changes: 5 additions & 2 deletions samples/Sentry.Samples.Maui/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,18 @@ public static MauiApp CreateMauiApp()
options.AddCommunityToolkitIntegration();

#if __ANDROID__
// Currently experimental support is only available on Android
// Currently, experimental support is only available on Android
options.Native.ExperimentalOptions.SessionReplay.OnErrorSampleRate = 1.0;
options.Native.ExperimentalOptions.SessionReplay.SessionSampleRate = 1.0;
// Mask all images and text by default. This can be overridden for individual view elements via the
// sentry:SessionReplay.Mask XML attribute (see MainPage.xaml for an example)
options.Native.ExperimentalOptions.SessionReplay.MaskAllImages = true;
options.Native.ExperimentalOptions.SessionReplay.MaskAllText = true;
// Alternatively the masking behaviour for entire classes of VisualElements can be configured here as
// Alternatively, the masking behaviour for entire classes of VisualElements can be configured here as
// an exception to the default behaviour.
// WARNING: In apps with complex user interfaces, consisting of hundreds of visual controls on a single
// page, this option may cause performance issues. In such cases, consider applying the
// sentry:SessionReplay.Mask="Unmask" attribute to individual controls instead.
options.Native.ExperimentalOptions.SessionReplay.UnmaskControlsOfType<Button>();
#endif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ namespace Sentry.Maui.Internal;
/// <summary>
/// Masks or unmasks visual elements for session replay recordings
/// </summary>
internal class MauiVisualElementEventsBinder : IMauiElementEventBinder
internal class MauiSessionReplayMaskControlsOfTypeBinder : IMauiElementEventBinder
{
private readonly SentryMauiOptions _options;

public MauiVisualElementEventsBinder(IOptions<SentryMauiOptions> options)
public MauiSessionReplayMaskControlsOfTypeBinder(IOptions<SentryMauiOptions> options)
{
_options = options.Value;
}
Expand Down
11 changes: 9 additions & 2 deletions src/Sentry.Maui/SentryMauiAppBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,18 @@ public static MauiAppBuilder UseSentry(this MauiAppBuilder builder,
services.AddSingleton<IMauiElementEventBinder, MauiButtonEventsBinder>();
services.AddSingleton<IMauiElementEventBinder, MauiImageButtonEventsBinder>();
services.AddSingleton<IMauiElementEventBinder, MauiGestureRecognizerEventsBinder>();
services.AddSingleton<IMauiElementEventBinder, MauiVisualElementEventsBinder>();

// Resolve the configured options and register any event binders that have been injected by integrations
// Resolve the configured options and register any event binders that have been enabled via configuration or
// injected by integrations
var options = new SentryMauiOptions();
configureOptions?.Invoke(options);
#if __ANDROID__
var replayOptions = options.Native.ExperimentalOptions.SessionReplay;
if (replayOptions is { IsSessionReplayEnabled: true, IsTypeMaskingUsed: true })
{
services.AddSingleton<IMauiElementEventBinder, MauiSessionReplayMaskControlsOfTypeBinder>();
}
#endif
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Session Replay Masking Fails Without Programmatic Configuration

The MauiCustomSessionReplayMaskBinder isn't registered unless programmatic type masking is configured. This causes XML attribute-based session replay masking (e.g., sentry:SessionReplay.Mask) to be ignored, contradicting the sample's guidance on using XML attributes as an alternative.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not true.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that untrue because

  • the BindableProperty does set the tags for native (""sentry-mask"" and "sentry-unmask") already
  • where the IMauiElementEventBinder must now do the same for all configured MaskedControls/UnmaskedControls

?
(just clarifying if I'm understanding it)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML attribute-based session replay masking works just fine without this MAUI binder. It's taken care of by our custom bindable properties (which are always enabled):

/// <summary>
/// Mask can be used to either unmask or mask a view.
/// </summary>
public static readonly BindableProperty MaskProperty =
BindableProperty.CreateAttached(
"Mask",
typeof(SessionReplayMaskMode),
typeof(SessionReplay),
defaultValue: SessionReplayMaskMode.Mask,
propertyChanged: OnMaskChanged);

Under the hood we use these to set the native android tags that are used by the Java SDK in the OnUnmaskedElementHandlerChanged and OnMaskedElementHandlerChanged handlers.

foreach (var eventBinder in options.IntegrationEventBinders)
{
eventBinder.Register(services);
Expand Down
25 changes: 25 additions & 0 deletions src/Sentry/Platforms/Android/NativeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -272,18 +272,43 @@ public class NativeSentryReplayOptions
public double? SessionSampleRate { get; set; }
public bool MaskAllImages { get; set; } = true;
public bool MaskAllText { get; set; } = true;

internal HashSet<Type> MaskedControls { get; } = [];
internal HashSet<Type> UnmaskedControls { get; } = [];

internal bool IsSessionReplayEnabled => OnErrorSampleRate > 0.0 || SessionSampleRate > 0.0;

/// <summary>
/// Allows you to mask all controls of a particular type for session replay recordings.
/// </summary>
/// <typeparam name="T">The Type of control that should be masked</typeparam>
/// <remarks>
/// WARNING: In apps with complex user interfaces, consisting of hundreds of visual controls on a single
/// page, this option may cause performance issues. In such cases, consider applying SessionReplay.Mask
/// attributes to individual controls instead:
/// <code>sentry:SessionReplay.Mask="Mask"</code>
/// </remarks>
public void MaskControlsOfType<T>()
{
MaskedControls.Add(typeof(T));
}

/// <summary>
/// Allows you to unmask all controls of a particular type for session replay recordings.
/// </summary>
/// <typeparam name="T">The Type of control that should be unmasked</typeparam>
/// <remarks>
/// WARNING: In apps with complex user interfaces, consisting of hundreds of visual controls on a single
/// page, this option may cause performance issues. In such cases, consider applying SessionReplay.Mask
/// attributes to individual controls instead:
/// <code>sentry:SessionReplay.Mask="Unmask"</code>
/// </remarks>
public void UnmaskControlsOfType<T>()
{
UnmaskedControls.Add(typeof(T));
}

internal bool IsTypeMaskingUsed => MaskedControls.Count > 0 || UnmaskedControls.Count > 0;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

namespace Sentry.Maui.Tests;

public class MauiVisualElementEventsBinderTests
public class MauiSessionReplayMaskControlsOfTypeBinderTests
{
private class Fixture
{
public MauiVisualElementEventsBinder Binder { get; }
public MauiSessionReplayMaskControlsOfTypeBinder ControlsOfTypeBinder { get; }

public SentryMauiOptions Options { get; } = new();

Expand All @@ -21,7 +21,7 @@ public Fixture()
logger.IsEnabled(Arg.Any<SentryLevel>()).Returns(true);
Options.DiagnosticLogger = logger;
var options = Microsoft.Extensions.Options.Options.Create(Options);
Binder = new MauiVisualElementEventsBinder(options);
ControlsOfTypeBinder = new MauiSessionReplayMaskControlsOfTypeBinder(options);
}
}

Expand All @@ -34,7 +34,7 @@ public void OnElementLoaded_SenderIsNotVisualElement_LogsDebugAndReturns()
var element = new MockElement("element");

// Act
_fixture.Binder.OnElementLoaded(element, EventArgs.Empty);
_fixture.ControlsOfTypeBinder.OnElementLoaded(element, EventArgs.Empty);

// Assert
_fixture.Options.DiagnosticLogger.Received(1).LogDebug("OnElementLoaded: sender is not a VisualElement");
Expand All @@ -50,7 +50,7 @@ public void OnElementLoaded_HandlerIsNull_LogsDebugAndReturns()
};

// Act
_fixture.Binder.OnElementLoaded(element, EventArgs.Empty);
_fixture.ControlsOfTypeBinder.OnElementLoaded(element, EventArgs.Empty);

// Assert
_fixture.Options.DiagnosticLogger.Received(1).LogDebug("OnElementLoaded: handler is null");
Expand Down
77 changes: 77 additions & 0 deletions test/Sentry.Maui.Tests/SentryMauiAppBuilderExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -329,4 +329,81 @@ public void UseSentry_DebugTrue_CustomDiagnosticsLogger()
// Assert
options.DiagnosticLogger.Should().BeOfType<TraceDiagnosticLogger>();
}

#if ANDROID
[Theory]
[InlineData(0.0, 1.0)]
[InlineData(1.0, 0.0)]
[InlineData(1.0, 1.0)]
public void UseSentry_SessionReplayEnabled_RegistersMauiSessionReplayMaskControlsOfTypeBinder(
double sessionSampleRate, double onErrorSampleRate)
{
// Arrange
var builder = _fixture.Builder;

// Act
builder.UseSentry(options =>
{
options.Dsn = ValidDsn;
// force custom masking to be enabled
options.Native.ExperimentalOptions.SessionReplay.MaskControlsOfType<object>();
// One of the below has to be non-zero for session replay to be enabled
options.Native.ExperimentalOptions.SessionReplay.SessionSampleRate = sessionSampleRate;
options.Native.ExperimentalOptions.SessionReplay.OnErrorSampleRate = onErrorSampleRate;
});

using var app = builder.Build();
var binders = app.Services.GetServices<IMauiElementEventBinder>();

// Assert
binders.Should().ContainSingle(b =>
b.GetType() == typeof(MauiSessionReplayMaskControlsOfTypeBinder));
}

[Fact]
public void UseSentry_SessionReplayDisabled_DoesNotRegisterMauiSessionReplayMaskControlsOfTypeBinder()
{
// Arrange
var builder = _fixture.Builder;

// Act
builder.UseSentry(options =>
{
options.Dsn = ValidDsn;
// force custom masking to be enabled
options.Native.ExperimentalOptions.SessionReplay.MaskControlsOfType<object>();
// No sessionSampleRate or onErrorSampleRate set... so should be disabled
});

using var app = builder.Build();
var binders = app.Services.GetServices<IMauiElementEventBinder>();

// Assert
binders.Should().NotContain(b =>
b.GetType() == typeof(MauiSessionReplayMaskControlsOfTypeBinder));
}

[Fact]
public void UseSentry_NoMaskedControls_DoesNotRegisterMauiVisualElementEventsBinder()
{
// Arrange
var builder = _fixture.Builder;

// Act
builder.UseSentry(options =>
{
options.Dsn = ValidDsn;
options.Native.ExperimentalOptions.SessionReplay.OnErrorSampleRate = 1.0;
options.Native.ExperimentalOptions.SessionReplay.SessionSampleRate = 1.0;
// Not really necessary, but just to be explicit
options.Native.ExperimentalOptions.SessionReplay.MaskedControls.Clear();
});

using var app = builder.Build();
var binders = app.Services.GetServices<IMauiElementEventBinder>();

// Assert
binders.Should().NotContain(b => b.GetType() == typeof(MauiSessionReplayMaskControlsOfTypeBinder));
}
#endif
}
Loading