diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a856c99..68fadb3f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - `SetBeforeCaptureScreenshot` signature changed from `Func` to `Func`, 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` to `Func`, 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 @@ -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)` callback that provides the captured + `ViewHierarchy` to be modified before compression. ([#2429](https://github.com/getsentry/sentry-unity/pull/2429)) ### Dependencies diff --git a/src/Sentry.Unity/SentryUnityOptions.cs b/src/Sentry.Unity/SentryUnityOptions.cs index a152d5458..de96de324 100644 --- a/src/Sentry.Unity/SentryUnityOptions.cs +++ b/src/Sentry.Unity/SentryUnityOptions.cs @@ -281,9 +281,23 @@ public void SetBeforeSendScreenshot(Func bef BeforeSendScreenshotInternal = beforeSendScreenshot; } - private Func? _beforeCaptureViewHierarchy; + internal Func? BeforeCaptureViewHierarchyInternal { get; private set; } - internal Func? BeforeCaptureViewHierarchyInternal => _beforeCaptureViewHierarchy; + internal Func? BeforeSendViewHierarchyInternal { get; private set; } + + /// + /// Configures a callback to modify or discard view hierarchy before it is sent. + /// + /// + /// 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. + /// + /// The callback function to invoke before sending view hierarchy. + public void SetBeforeSendViewHierarchy(Func beforeSendViewHierarchy) + { + BeforeSendViewHierarchyInternal = beforeSendViewHierarchy; + } /// /// Configures a callback function to be invoked before capturing and attaching the view hierarchy to an event. @@ -292,9 +306,9 @@ public void SetBeforeSendScreenshot(Func bef /// This callback will get invoked right before the view hierarchy gets taken. If the view hierarchy should not /// be taken return `false`. /// - public void SetBeforeCaptureViewHierarchy(Func beforeAttachViewHierarchy) + public void SetBeforeCaptureViewHierarchy(Func beforeAttachViewHierarchy) { - _beforeCaptureViewHierarchy = beforeAttachViewHierarchy; + BeforeCaptureViewHierarchyInternal = beforeAttachViewHierarchy; } // Initialized by native SDK binding code to set the User.ID in .NET (UnityEventProcessor). diff --git a/src/Sentry.Unity/UnityViewHierarchyNode.cs b/src/Sentry.Unity/UnityViewHierarchyNode.cs index 08da6e468..1dfc09cb6 100644 --- a/src/Sentry.Unity/UnityViewHierarchyNode.cs +++ b/src/Sentry.Unity/UnityViewHierarchyNode.cs @@ -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; } diff --git a/src/Sentry.Unity/ViewHierarchyEventProcessor.cs b/src/Sentry.Unity/ViewHierarchyEventProcessor.cs index 0ef342a0b..9cf4ab9f0 100644 --- a/src/Sentry.Unity/ViewHierarchyEventProcessor.cs +++ b/src/Sentry.Unity/ViewHierarchyEventProcessor.cs @@ -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(); diff --git a/test/Sentry.Unity.Tests/ViewHierarchyEventProcessorTests.cs b/test/Sentry.Unity.Tests/ViewHierarchyEventProcessorTests.cs index 93ec9dfc4..57e9ea3f6 100644 --- a/test/Sentry.Unity.Tests/ViewHierarchyEventProcessorTests.cs +++ b/test/Sentry.Unity.Tests/ViewHierarchyEventProcessorTests.cs @@ -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(); @@ -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)); @@ -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--;