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");
+ }
}