diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 67318abc54..9c61f62228 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -1,6 +1,7 @@ using Sentry.Extensibility; using Sentry.Infrastructure; using Sentry.Integrations; +using Sentry.Internal.Extensions; using Sentry.Protocol.Envelopes; using Sentry.Protocol.Metrics; @@ -757,6 +758,28 @@ public SentryId CaptureCheckIn( return SentryId.Empty; } + // Internal capture method that allows the Unity SDK to send attachments after an already captured event. + // Kept internal as the preferred way of adding attachments is either on the scope or directly on the event. + // See https://develop.sentry.dev/sdk/data-model/envelope-items/#attachment + internal bool CaptureAttachment(SentryId eventId, SentryAttachment attachment) + { + if (!IsEnabled || eventId == SentryId.Empty || attachment.IsNull()) + { + return false; + } + + try + { + var envelope = Envelope.FromAttachment(eventId, attachment, _options.DiagnosticLogger); + return CaptureEnvelope(envelope); + } + catch (Exception e) + { + _options.LogError(e, "Failure to capture attachment"); + return false; + } + } + public async Task FlushAsync(TimeSpan timeout) { try diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index b62dc82c98..0c6dec0281 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -445,6 +445,12 @@ internal static Envelope FromClientReport(ClientReport clientReport) return new Envelope(header, items); } + /// + /// Creates an envelope that contains only an attachment for an existing event. + /// + internal static Envelope FromAttachment(SentryId eventId, SentryAttachment attachment, IDiagnosticLogger? logger = null) => + new(eventId, CreateHeader(eventId), [EnvelopeItem.FromAttachment(attachment)]); + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/test/Sentry.Testing/NullAttachmentContent.cs b/test/Sentry.Testing/NullAttachmentContent.cs new file mode 100644 index 0000000000..ea96a54e5c --- /dev/null +++ b/test/Sentry.Testing/NullAttachmentContent.cs @@ -0,0 +1,10 @@ +namespace Sentry.Testing; + +internal sealed class NullAttachmentContent : IAttachmentContent +{ + public static NullAttachmentContent Instance { get; } = new(); + + public Stream GetStream() => Stream.Null; + + private NullAttachmentContent() { } +} diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index 85bf533df9..acd9eef972 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -1730,6 +1730,67 @@ public void CaptureFeedback_ConfigureScope_ScopeApplied(bool enabled) _fixture.Client.Received(enabled ? 1 : 0).CaptureFeedback(Arg.Any(), Arg.Is(s => s.Tags["foo"] == "bar"), Arg.Any()); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CaptureAttachment_HubEnabled(bool enabled) + { + // Arrange + var hub = _fixture.GetSut(); + if (!enabled) + { + hub.Dispose(); + } + + _fixture.Client.CaptureEnvelope(Arg.Any()).Returns(true); + + var eventId = SentryId.Create(); + var attachment = new SentryAttachment( + AttachmentType.Default, + new ByteAttachmentContent("test content"u8.ToArray()), + "test.txt", + "text/plain"); + + // Act + var result = hub.CaptureAttachment(eventId, attachment); + + // Assert + result.Should().Be(enabled); + _fixture.Client.Received(enabled ? 1 : 0).CaptureEnvelope(Arg.Any()); + } + + [Fact] + public void CaptureAttachment_SentryIdEmpty_ReturnsFalse() + { + // Arrange + var hub = _fixture.GetSut(); + + var eventId = SentryId.Empty; + var attachment = new SentryAttachment(AttachmentType.Default, NullAttachmentContent.Instance, "test.txt", "text/plain"); + + // Act + var result = hub.CaptureAttachment(eventId, attachment); + + // Assert + result.Should().Be(false); + _fixture.Client.DidNotReceive().CaptureEnvelope(Arg.Any()); + } + + [Fact] + public void CaptureAttachment_AttachmentNull_ReturnsFalse() + { + // Arrange + var hub = _fixture.GetSut(); + var eventId = SentryId.Create(); + + // Act + var result = hub.CaptureAttachment(eventId, null!); + + // Assert + result.Should().Be(false); + _fixture.Client.DidNotReceive().CaptureEnvelope(Arg.Any()); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs index d1715c54ff..60f2fb98a4 100644 --- a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs +++ b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs @@ -1090,4 +1090,26 @@ public async Task Serialization_RoundTrip_RecalculatesLengthHeader() Assert.Contains("""{"foo":"2020-01-01T00:00:00+01:00"}""", serialized1); Assert.Contains("""{"foo":"2020-01-01T00:00:00\u002B01:00"}""", serialized2); } + + [Fact] + public void FromAttachment_ValidAttachment_CreatesEnvelope() + { + // Arrange + var eventId = SentryId.Create(); + var attachment = new SentryAttachment( + AttachmentType.Default, + new ByteAttachmentContent("test content"u8.ToArray()), + "test.txt", + "text/plain"); + + // Act + using var envelope = Envelope.FromAttachment(eventId, attachment); + + // Assert + envelope.TryGetEventId().Should().Be(eventId); + envelope.Items.Should().HaveCount(1); + envelope.Items[0].Header["type"].Should().Be("attachment"); + envelope.Items[0].Header["filename"].Should().Be("test.txt"); + envelope.Items[0].Header["content_type"].Should().Be("text/plain"); + } }