Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

- `SetBeforeCaptureScreenshot` signature changed from `Func<bool>` to `Func<SentryEvent, bool>`, now receiving the event that
triggered the screenshot capture. This allows context-aware decisions before capture begins. ([#2428](https://github.com/getsentry/sentry-unity/pull/2428))
- `SetBeforeCaptureViewHierarchy` signature changed from `Func<bool>` to `Func<SentryEvent, bool>`, now receiving the event that
triggered the view hierarchy capture. This allows context-aware decisions before capture begins. ([#2429](https://github.com/getsentry/sentry-unity/pull/2429))

### Features

Expand All @@ -16,6 +18,8 @@
- **Replacing** the screenshot with a different `Texture2D`
- **Discarding** the screenshot by returning `null`
- Access to the event context for conditional processing
- Added `SetBeforeSendViewHierarchy(Func<ViewHierarchy, SentryEvent, ViewHierarchy?>)` callback that provides the captured
`ViewHierarchy` to be modified before compression. ([#2429](https://github.com/getsentry/sentry-unity/pull/2429))

### Dependencies

Expand Down
22 changes: 18 additions & 4 deletions src/Sentry.Unity/SentryUnityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,23 @@ public void SetBeforeSendScreenshot(Func<Texture2D, SentryEvent, Texture2D?> bef
BeforeSendScreenshotInternal = beforeSendScreenshot;
}

private Func<bool>? _beforeCaptureViewHierarchy;
internal Func<SentryEvent, bool>? BeforeCaptureViewHierarchyInternal { get; private set; }

internal Func<bool>? BeforeCaptureViewHierarchyInternal => _beforeCaptureViewHierarchy;
internal Func<ViewHierarchy, SentryEvent, ViewHierarchy?>? BeforeSendViewHierarchyInternal { get; private set; }

/// <summary>
/// Configures a callback to modify or discard view hierarchy before it is sent.
/// </summary>
/// <remarks>
/// This callback receives the captured view hierarchy before JSON serialization.
/// You can modify the hierarchy structure (remove nodes, filter sensitive info, etc.)
/// and return it, or return null to discard.
/// </remarks>
/// <param name="beforeSendViewHierarchy">The callback function to invoke before sending view hierarchy.</param>
public void SetBeforeSendViewHierarchy(Func<ViewHierarchy, SentryEvent, ViewHierarchy?> beforeSendViewHierarchy)
{
BeforeSendViewHierarchyInternal = beforeSendViewHierarchy;
}

/// <summary>
/// Configures a callback function to be invoked before capturing and attaching the view hierarchy to an event.
Expand All @@ -292,9 +306,9 @@ public void SetBeforeSendScreenshot(Func<Texture2D, SentryEvent, Texture2D?> bef
/// This callback will get invoked right before the view hierarchy gets taken. If the view hierarchy should not
/// be taken return `false`.
/// </remarks>
public void SetBeforeCaptureViewHierarchy(Func<bool> beforeAttachViewHierarchy)
public void SetBeforeCaptureViewHierarchy(Func<SentryEvent, bool> beforeAttachViewHierarchy)
{
_beforeCaptureViewHierarchy = beforeAttachViewHierarchy;
BeforeCaptureViewHierarchyInternal = beforeAttachViewHierarchy;
}

// Initialized by native SDK binding code to set the User.ID in .NET (UnityEventProcessor).
Expand Down
2 changes: 1 addition & 1 deletion src/Sentry.Unity/UnityViewHierarchyNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Sentry.Unity;

internal class UnityViewHierarchyNode : ViewHierarchyNode
public class UnityViewHierarchyNode : ViewHierarchyNode
{
public string? Tag { get; set; }
public string? Position { get; set; }
Expand Down
30 changes: 21 additions & 9 deletions src/Sentry.Unity/ViewHierarchyEventProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,39 @@ public ViewHierarchyEventProcessor(SentryUnityOptions sentryOptions)
return @event;
}

if (_options.BeforeCaptureViewHierarchyInternal?.Invoke() is not false)
if (_options.BeforeCaptureViewHierarchyInternal?.Invoke(@event) is false)
{
hint.AddAttachment(CaptureViewHierarchy(), "view-hierarchy.json", AttachmentType.ViewHierarchy, "application/json");
_options.DiagnosticLogger?.LogInfo("Hierarchy capture skipped by BeforeCaptureViewHierarchy callback.");
return @event;
}
else

var viewHierarchy = CreateViewHierarchy(
_options.MaxViewHierarchyRootObjects,
_options.MaxViewHierarchyObjectChildCount,
_options.MaxViewHierarchyDepth);

if (_options.BeforeSendViewHierarchyInternal != null)
{
_options.DiagnosticLogger?.LogInfo("Hierarchy capture skipped by BeforeAttachViewHierarchy callback.");
viewHierarchy = _options.BeforeSendViewHierarchyInternal(viewHierarchy, @event);

if (viewHierarchy == null)
{
_options.DiagnosticLogger?.LogInfo("View hierarchy discarded by BeforeSendViewHierarchy callback.");
return @event;
}
}

var bytes = SerializeViewHierarchy(viewHierarchy);
hint.AddAttachment(bytes, "view-hierarchy.json", AttachmentType.ViewHierarchy, "application/json");

return @event;
}

internal byte[] CaptureViewHierarchy()
internal byte[] SerializeViewHierarchy(ViewHierarchy viewHierarchy)
{
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream);

var viewHierarchy = CreateViewHierarchy(
_options.MaxViewHierarchyRootObjects,
_options.MaxViewHierarchyObjectChildCount,
_options.MaxViewHierarchyDepth);
viewHierarchy.WriteTo(writer, _options.DiagnosticLogger);

writer.Flush();
Expand Down
117 changes: 114 additions & 3 deletions test/Sentry.Unity.Tests/ViewHierarchyEventProcessorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public void Process_IsNonMainThread_DoesNotAddViewHierarchyToHint()
[TestCase(false)]
public void Process_BeforeCaptureViewHierarchyCallbackProvided_RespectViewHierarchyCaptureDecision(bool captureViewHierarchy)
{
_fixture.Options.SetBeforeCaptureViewHierarchy(() => captureViewHierarchy);
_fixture.Options.SetBeforeCaptureViewHierarchy(_ => captureViewHierarchy);
var sut = _fixture.GetSut();
var sentryEvent = new SentryEvent();
var hint = new SentryHint();
Expand All @@ -76,11 +76,12 @@ public void Process_BeforeCaptureViewHierarchyCallbackProvided_RespectViewHierar
}

[Test]
public void CaptureViewHierarchy_ReturnsNonNullOrEmptyByteArray()
public void SerializeViewHierarchy_ReturnsNonNullOrEmptyByteArray()
{
var sut = _fixture.GetSut();
var viewHierarchy = sut.CreateViewHierarchy(1, 1, 1);

var byteArray = sut.CaptureViewHierarchy();
var byteArray = sut.SerializeViewHierarchy(viewHierarchy);

Assert.That(byteArray, Is.Not.Null);
Assert.That(byteArray.Length, Is.GreaterThan(0));
Expand Down Expand Up @@ -184,6 +185,116 @@ public void CreateNode_LessChildrenThanMaxChildCount_CapturesViewHierarchy()
Assert.AreEqual(3, root.Children[0].Children.Count);
}

[Test]
public void Process_BeforeSendViewHierarchyCallback_ReceivesViewHierarchyAndEvent()
{
ViewHierarchy? receivedViewHierarchy = null;
SentryEvent? receivedEvent = null;

_fixture.Options.SetBeforeSendViewHierarchy((viewHierarchy, @event) =>
{
receivedViewHierarchy = viewHierarchy;
receivedEvent = @event;
return viewHierarchy;
});

var sut = _fixture.GetSut();
var sentryEvent = new SentryEvent();
var hint = new SentryHint();

sut.Process(sentryEvent, hint);

Assert.NotNull(receivedViewHierarchy);
Assert.NotNull(receivedEvent);
Assert.AreEqual(sentryEvent.EventId, receivedEvent!.EventId);
Assert.AreEqual(1, hint.Attachments.Count);
}

[Test]
public void Process_BeforeSendViewHierarchyCallback_ReturnsNull_SkipsAttachment()
{
_fixture.Options.SetBeforeSendViewHierarchy((_, _) => null);

var sut = _fixture.GetSut();
var sentryEvent = new SentryEvent();
var hint = new SentryHint();

sut.Process(sentryEvent, hint);

Assert.AreEqual(0, hint.Attachments.Count);
}

[Test]
public void Process_BeforeSendViewHierarchyCallback_ModifiesHierarchy_UsesModifiedVersion()
{
var callbackInvoked = false;

_fixture.Options.SetBeforeSendViewHierarchy((viewHierarchy, @event) =>
{
callbackInvoked = true;
// Remove all children from the root window
viewHierarchy.Windows[0].Children.Clear();
return viewHierarchy;
});

var sut = _fixture.GetSut();

// Create some game objects so there's something to remove
for (var i = 0; i < 3; i++)
{
var _ = new GameObject($"GameObject_{i}");
}

var sentryEvent = new SentryEvent();
var hint = new SentryHint();

sut.Process(sentryEvent, hint);

Assert.IsTrue(callbackInvoked);
Assert.AreEqual(1, hint.Attachments.Count);

// Verify the modification was applied by deserializing
var attachment = hint.Attachments.First();
var content = attachment.Content as ByteAttachmentContent;
Assert.NotNull(content);

using var stream = content!.GetStream();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();

// The JSON should show an empty children array
Assert.That(json, Does.Contain("\"children\":[]"));
}

[Test]
public void Process_BeforeSendViewHierarchyCallback_ReturnsDifferentHierarchy_UsesNewHierarchy()
{
var newHierarchy = new ViewHierarchy("CustomRenderingSystem");
newHierarchy.Windows.Add(new UnityViewHierarchyNode("CustomWindow"));

_fixture.Options.SetBeforeSendViewHierarchy((_, _) => newHierarchy);

var sut = _fixture.GetSut();
var sentryEvent = new SentryEvent();
var hint = new SentryHint();

sut.Process(sentryEvent, hint);

Assert.AreEqual(1, hint.Attachments.Count);

// Verify the new hierarchy was used
var attachment = hint.Attachments.First();
var content = attachment.Content as ByteAttachmentContent;
Assert.NotNull(content);

using var stream = content!.GetStream();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();

Assert.That(json, Does.Contain("CustomRenderingSystem"));
Assert.That(json, Does.Contain("CustomWindow"));
}

private void CreateTestHierarchy(int remainingDepth, int childCount, Transform parent)
{
remainingDepth--;
Expand Down