diff --git a/src/Sentry.Maui/Internal/ScreenshotAttachment.cs b/src/Sentry.Maui/Internal/ScreenshotAttachment.cs index 6d8a2e7cf6..df18fba25f 100644 --- a/src/Sentry.Maui/Internal/ScreenshotAttachment.cs +++ b/src/Sentry.Maui/Internal/ScreenshotAttachment.cs @@ -5,22 +5,13 @@ namespace Sentry.Maui.Internal; internal class ScreenshotAttachment : SentryAttachment { public ScreenshotAttachment(SentryMauiOptions options) - : this( + : base( AttachmentType.Default, new ScreenshotAttachmentContent(options), "screenshot.jpg", "image/jpeg") { } - - private ScreenshotAttachment( - AttachmentType type, - IAttachmentContent content, - string fileName, - string? contentType) - : base(type, content, fileName, contentType) - { - } } internal class ScreenshotAttachmentContent : IAttachmentContent diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index 227c572797..e70a80526f 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -335,9 +335,12 @@ public static Envelope FromFeedback( } /// - /// Creates an envelope that contains a single transaction. + /// Creates an envelope that contains a single transaction and optional attachments. /// - public static Envelope FromTransaction(SentryTransaction transaction) + public static Envelope FromTransaction( + SentryTransaction transaction, + IDiagnosticLogger? logger = null, + IReadOnlyCollection? attachments = null) { var eventId = transaction.EventId; var header = CreateHeader(eventId, transaction.DynamicSamplingContext); @@ -358,6 +361,20 @@ public static Envelope FromTransaction(SentryTransaction transaction) } } + if (attachments is not null) + { + foreach (var attachment in attachments) + { + if (attachment.IsNull()) + { + logger?.LogWarning("Encountered a null attachment. Skipping."); + continue; + } + + AddEnvelopeItemFromAttachment(items, attachment, logger); + } + } + return new Envelope(eventId, header, items); } diff --git a/src/Sentry/SentryAttachment.cs b/src/Sentry/SentryAttachment.cs index 0249fc589f..c57dc96ed4 100644 --- a/src/Sentry/SentryAttachment.cs +++ b/src/Sentry/SentryAttachment.cs @@ -65,6 +65,12 @@ public class SentryAttachment /// public string? ContentType { get; } + /// + /// Whether the attachment should be added to transactions. + /// Defaults to false. + /// + public bool AddToTransactions { get; } + /// /// Initializes an instance of . /// @@ -73,10 +79,24 @@ public SentryAttachment( IAttachmentContent content, string fileName, string? contentType) + : this(type, content, fileName, contentType, false) + { + } + + /// + /// Initializes an instance of . + /// + public SentryAttachment( + AttachmentType type, + IAttachmentContent content, + string fileName, + string? contentType, + bool addToTransactions) { Type = type; Content = content; FileName = fileName; ContentType = contentType; + AddToTransactions = addToTransactions; } } diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 7bbd403aef..8bded1c002 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -223,7 +223,10 @@ public void CaptureTransaction(SentryTransaction transaction, Scope? scope, Sent processedTransaction.Redact(); } - CaptureEnvelope(Envelope.FromTransaction(processedTransaction)); + // Keep null entries so the null-attachment guard in Envelope.FromTransaction handles them + // (consistent with the event/feedback capture paths); dereferencing them here would throw. + var attachments = hint.Attachments.Where(a => a is null || a.AddToTransactions).ToList(); + CaptureEnvelope(Envelope.FromTransaction(processedTransaction, _options.DiagnosticLogger, attachments)); } #if NET6_0_OR_GREATER diff --git a/src/Sentry/ViewHierarchyAttachment.cs b/src/Sentry/ViewHierarchyAttachment.cs index d71e9e8db5..658ea52305 100644 --- a/src/Sentry/ViewHierarchyAttachment.cs +++ b/src/Sentry/ViewHierarchyAttachment.cs @@ -8,8 +8,17 @@ public class ViewHierarchyAttachment : SentryAttachment /// /// Initializes an instance of . /// - /// /// The view hierarchy attachment - public ViewHierarchyAttachment(IAttachmentContent content) : - base(AttachmentType.ViewHierarchy, content, "view-hierarchy.json", "application/json") + /// The view hierarchy attachment + public ViewHierarchyAttachment(IAttachmentContent content) + : this(content, false) + { } + + /// + /// Initializes an instance of . + /// + /// The view hierarchy attachment + /// Whether the attachment should be added to transactions. + public ViewHierarchyAttachment(IAttachmentContent content, bool addToTransactions) + : base(AttachmentType.ViewHierarchy, content, "view-hierarchy.json", "application/json", addToTransactions) { } } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt index 5bc79de2f1..fe4834dee2 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet10_0.verified.txt @@ -438,6 +438,8 @@ namespace Sentry public class SentryAttachment { public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType) { } + public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType, bool addToTransactions) { } + public bool AddToTransactions { get; } public Sentry.IAttachmentContent Content { get; } public string? ContentType { get; } public string FileName { get; } @@ -1415,6 +1417,7 @@ namespace Sentry public class ViewHierarchyAttachment : Sentry.SentryAttachment { public ViewHierarchyAttachment(Sentry.IAttachmentContent content) { } + public ViewHierarchyAttachment(Sentry.IAttachmentContent content, bool addToTransactions) { } } public abstract class ViewHierarchyNode : Sentry.ISentryJsonSerializable { @@ -2017,7 +2020,7 @@ namespace Sentry.Protocol.Envelopes public static Sentry.Protocol.Envelopes.Envelope FromEvent(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromFeedback(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromSession(Sentry.SessionUpdate sessionUpdate) { } - public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction) { } + public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null) { } } public sealed class EnvelopeItem : Sentry.Protocol.Envelopes.ISerializable, System.IDisposable { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt index 5bc79de2f1..fe4834dee2 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt @@ -438,6 +438,8 @@ namespace Sentry public class SentryAttachment { public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType) { } + public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType, bool addToTransactions) { } + public bool AddToTransactions { get; } public Sentry.IAttachmentContent Content { get; } public string? ContentType { get; } public string FileName { get; } @@ -1415,6 +1417,7 @@ namespace Sentry public class ViewHierarchyAttachment : Sentry.SentryAttachment { public ViewHierarchyAttachment(Sentry.IAttachmentContent content) { } + public ViewHierarchyAttachment(Sentry.IAttachmentContent content, bool addToTransactions) { } } public abstract class ViewHierarchyNode : Sentry.ISentryJsonSerializable { @@ -2017,7 +2020,7 @@ namespace Sentry.Protocol.Envelopes public static Sentry.Protocol.Envelopes.Envelope FromEvent(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromFeedback(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromSession(Sentry.SessionUpdate sessionUpdate) { } - public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction) { } + public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null) { } } public sealed class EnvelopeItem : Sentry.Protocol.Envelopes.ISerializable, System.IDisposable { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt index 5bc79de2f1..fe4834dee2 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt @@ -438,6 +438,8 @@ namespace Sentry public class SentryAttachment { public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType) { } + public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType, bool addToTransactions) { } + public bool AddToTransactions { get; } public Sentry.IAttachmentContent Content { get; } public string? ContentType { get; } public string FileName { get; } @@ -1415,6 +1417,7 @@ namespace Sentry public class ViewHierarchyAttachment : Sentry.SentryAttachment { public ViewHierarchyAttachment(Sentry.IAttachmentContent content) { } + public ViewHierarchyAttachment(Sentry.IAttachmentContent content, bool addToTransactions) { } } public abstract class ViewHierarchyNode : Sentry.ISentryJsonSerializable { @@ -2017,7 +2020,7 @@ namespace Sentry.Protocol.Envelopes public static Sentry.Protocol.Envelopes.Envelope FromEvent(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromFeedback(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromSession(Sentry.SessionUpdate sessionUpdate) { } - public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction) { } + public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null) { } } public sealed class EnvelopeItem : Sentry.Protocol.Envelopes.ISerializable, System.IDisposable { diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 6c44d57159..3e8e2017ec 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -426,6 +426,8 @@ namespace Sentry public class SentryAttachment { public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType) { } + public SentryAttachment(Sentry.AttachmentType type, Sentry.IAttachmentContent content, string fileName, string? contentType, bool addToTransactions) { } + public bool AddToTransactions { get; } public Sentry.IAttachmentContent Content { get; } public string? ContentType { get; } public string FileName { get; } @@ -1396,6 +1398,7 @@ namespace Sentry public class ViewHierarchyAttachment : Sentry.SentryAttachment { public ViewHierarchyAttachment(Sentry.IAttachmentContent content) { } + public ViewHierarchyAttachment(Sentry.IAttachmentContent content, bool addToTransactions) { } } public abstract class ViewHierarchyNode : Sentry.ISentryJsonSerializable { @@ -1993,7 +1996,7 @@ namespace Sentry.Protocol.Envelopes public static Sentry.Protocol.Envelopes.Envelope FromEvent(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromFeedback(Sentry.SentryEvent @event, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null, Sentry.SessionUpdate? sessionUpdate = null) { } public static Sentry.Protocol.Envelopes.Envelope FromSession(Sentry.SessionUpdate sessionUpdate) { } - public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction) { } + public static Sentry.Protocol.Envelopes.Envelope FromTransaction(Sentry.SentryTransaction transaction, Sentry.Extensibility.IDiagnosticLogger? logger = null, System.Collections.Generic.IReadOnlyCollection? attachments = null) { } } public sealed class EnvelopeItem : Sentry.Protocol.Envelopes.ISerializable, System.IDisposable { diff --git a/test/Sentry.Tests/AttachmentHelper.cs b/test/Sentry.Tests/AttachmentHelper.cs index 562313fb2a..daa2e7b640 100644 --- a/test/Sentry.Tests/AttachmentHelper.cs +++ b/test/Sentry.Tests/AttachmentHelper.cs @@ -2,12 +2,13 @@ namespace Sentry.Tests { internal static class AttachmentHelper { - internal static SentryAttachment FakeAttachment(string name = "test.txt") + internal static SentryAttachment FakeAttachment(string name = "test.txt", bool addToTransactions = false) => new( AttachmentType.Default, new StreamAttachmentContent(new MemoryStream(new byte[] { 1 })), name, - null + null, + addToTransactions ); } } diff --git a/test/Sentry.Tests/AttachmentTests.cs b/test/Sentry.Tests/AttachmentTests.cs index 95a3666eca..7e39ba599a 100644 --- a/test/Sentry.Tests/AttachmentTests.cs +++ b/test/Sentry.Tests/AttachmentTests.cs @@ -24,6 +24,28 @@ public void GetStream_ReturnsBytesContent() } } +public class ViewHierarchyAttachmentTests +{ + [Fact] + public void Ctor_DefaultsAddToTransactionsToFalse() + { + var attachment = new ViewHierarchyAttachment(new ByteAttachmentContent(new byte[] { 1 })); + Assert.False(attachment.AddToTransactions); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Ctor_ForwardsAddToTransactions(bool addToTransactions) + { + var attachment = new ViewHierarchyAttachment( + new ByteAttachmentContent(new byte[] { 1 }), + addToTransactions); + + Assert.Equal(addToTransactions, attachment.AddToTransactions); + } +} + public class FileAttachmentContentTests { [Fact] diff --git a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs index 1835016cf9..732820851e 100644 --- a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs +++ b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs @@ -753,6 +753,34 @@ public async Task Roundtrip_WithEvent_WithAttachment_Success() envelopeRoundtrip.Items[1].TryGetOrRecalculateLength().Should().Be(attachment.Content.GetStream().Length); } + [Fact] + public void FromTransaction_WithNullAttachment_SkipsItAndLogsWarning() + { + // Arrange + var tracer = new TransactionTracer(DisabledHub.Instance, "name", "op"); + var transaction = new SentryTransaction(tracer); + + using var attachmentStream = new MemoryStream(new byte[] { 1, 2, 3 }); + var validAttachment = new SentryAttachment( + AttachmentType.Default, + new StreamAttachmentContent(attachmentStream), + "file.txt", + null); + + var attachments = new List { null!, validAttachment }; + var logger = new InMemoryDiagnosticLogger(); + + // Act + using var envelope = Envelope.FromTransaction(transaction, logger, attachments); + + // Assert + // Only the transaction item and the single valid attachment - the null is skipped. + envelope.Items.Count(item => item.TryGetType() == "attachment").Should().Be(1); + logger.Entries.Should().ContainSingle(e => + e.Level == SentryLevel.Warning && + e.Message == "Encountered a null attachment. Skipping."); + } + [Fact] public async Task Roundtrip_WithEvent_WithSession_Success() { diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index 1accc76921..3cb379e9d6 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -1410,6 +1410,75 @@ public void CaptureTransaction_ScopeContainsAttachments_GetAppliedToHint() hint.Attachments.Should().Contain(attachments); } + [Fact] + public void CaptureTransaction_AttachmentWithAddToTransactionsTrue_IncludedInEnvelope() + { + // Arrange + var transaction = new SentryTransaction("name", "operation") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now + }; + var attachment = AttachmentHelper.FakeAttachment("include.txt", addToTransactions: true); + var scope = new Scope(_fixture.SentryOptions); + scope.AddAttachment(attachment); + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(transaction, scope, null); + + // Assert + sut.Worker.Received(1).EnqueueEnvelope(Arg.Is(envelope => + envelope.Items.Count(item => item.TryGetType() == "attachment") == 1)); + } + + [Fact] + public void CaptureTransaction_AttachmentWithAddToTransactionsFalse_ExcludedFromEnvelope() + { + // Arrange + var transaction = new SentryTransaction("name", "operation") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now + }; + var scope = new Scope(_fixture.SentryOptions); + scope.AddAttachment(AttachmentHelper.FakeAttachment("exclude.txt")); // default: AddToTransactions = false + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(transaction, scope, null); + + // Assert + sut.Worker.Received(1).EnqueueEnvelope(Arg.Is(envelope => + envelope.Items.Count(item => item.TryGetType() == "attachment") == 0)); + } + + [Fact] + public void CaptureTransaction_NullAttachmentInHint_DoesNotThrowAndSkipsNull() + { + // Arrange + var transaction = new SentryTransaction("name", "operation") + { + IsSampled = true, + EndTimestamp = DateTimeOffset.Now + }; + var scope = new Scope(_fixture.SentryOptions); + var sut = _fixture.GetSut(); + + // A null entry in the hint's attachments must not crash transaction capture. + var hint = new SentryHint(); + hint.Attachments.Add(null!); + hint.Attachments.Add(AttachmentHelper.FakeAttachment("include.txt", addToTransactions: true)); + + // Act + var capture = () => sut.CaptureTransaction(transaction, scope, hint); + + // Assert + capture.Should().NotThrow(); + sut.Worker.Received(1).EnqueueEnvelope(Arg.Is(envelope => + envelope.Items.Count(item => item.TryGetType() == "attachment") == 1)); + } + [SkippableFact] public void CaptureTransaction_UserIsNull_SetsFallbackUserId() {