Repository: getsentry/sentry-go

Issue:
Support for attachments in go sdk
### Problem Statement

It will be great if you could add the support for attachments for go SDK. 

### Solution Brainstorm

_No response_

Discussion:
@getsantry: Assigning to @getsentry/support for [routing](https://open.sentry.io/triage/#2-route), due by **<time datetime=2023-02-07T20:19:03.681Z>Tuesday, February 7th at 15:19</time> (yyz)**. ⏲️@hubertdeng123: Going to transfer this issue to the go sdk repo@pavelfurm: Thank you!

Solution patch:
diff --git a/interfaces.go b/interfaces.go
index f872294..4977928 100644
--- a/interfaces.go
+++ b/interfaces.go
@@ -108,6 +108,14 @@ func (b *Breadcrumb) MarshalJSON() ([]byte, error) {
 	return json.Marshal((*breadcrumb)(b))
 }
 
+// Attachment allows associating files with your events to aid in investigation.
+// An event may contain one or more attachments.
+type Attachment struct {
+	Filename    string
+	ContentType string
+	Payload     []byte
+}
+
 // User describes the user associated with an Event. If this is used, at least
 // an ID or an IP address should be provided.
 type User struct {
@@ -326,6 +334,7 @@ type Event struct {
 	// The fields below are not part of the final JSON payload.
 
 	sdkMetaData SDKMetaData
+	attachments []*Attachment
 }
 
 // SetException appends the unwrapped errors to the event's exception list.
diff --git a/scope.go b/scope.go
index 52cb761..2f961ba 100644
--- a/scope.go
+++ b/scope.go
@@ -25,6 +25,7 @@ import (
 type Scope struct {
 	mu          sync.RWMutex
 	breadcrumbs []*Breadcrumb
+	attachments []*Attachment
 	user        User
 	tags        map[string]string
 	contexts    map[string]Context
@@ -48,6 +49,7 @@ type Scope struct {
 func NewScope() *Scope {
 	scope := Scope{
 		breadcrumbs: make([]*Breadcrumb, 0),
+		attachments: make([]*Attachment, 0),
 		tags:        make(map[string]string),
 		contexts:    make(map[string]Context),
 		extra:       make(map[string]interface{}),
@@ -81,6 +83,22 @@ func (scope *Scope) ClearBreadcrumbs() {
 	scope.breadcrumbs = []*Breadcrumb{}
 }
 
+// AddAttachment adds new attachment to the current scope.
+func (scope *Scope) AddAttachment(attachment *Attachment) {
+	scope.mu.Lock()
+	defer scope.mu.Unlock()
+
+	scope.attachments = append(scope.attachments, attachment)
+}
+
+// ClearAttachments clears all attachments from the current scope.
+func (scope *Scope) ClearAttachments() {
+	scope.mu.Lock()
+	defer scope.mu.Unlock()
+
+	scope.attachments = []*Attachment{}
+}
+
 // SetUser sets the user for the current scope.
 func (scope *Scope) SetUser(user User) {
 	scope.mu.Lock()
@@ -283,6 +301,8 @@ func (scope *Scope) Clone() *Scope {
 	clone.user = scope.user
 	clone.breadcrumbs = make([]*Breadcrumb, len(scope.breadcrumbs))
 	copy(clone.breadcrumbs, scope.breadcrumbs)
+	clone.attachments = make([]*Attachment, len(scope.attachments))
+	copy(clone.attachments, scope.attachments)
 	for key, value := range scope.tags {
 		clone.tags[key] = value
 	}
@@ -323,6 +343,10 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint) *Event {
 		event.Breadcrumbs = append(event.Breadcrumbs, scope.breadcrumbs...)
 	}
 
+	if len(scope.attachments) > 0 {
+		event.attachments = append(event.attachments, scope.attachments...)
+	}
+
 	if len(scope.tags) > 0 {
 		if event.Tags == nil {
 			event.Tags = make(map[string]string, len(scope.tags))
diff --git a/transport.go b/transport.go
index d8bdf2a..ba54472 100644
--- a/transport.go
+++ b/transport.go
@@ -94,6 +94,38 @@ func getRequestBodyFromEvent(event *Event) []byte {
 	return nil
 }
 
+func encodeAttachment(enc *json.Encoder, b io.Writer, attachment *Attachment) error {
+	// Attachment header
+	err := enc.Encode(struct {
+		Type        string `json:"type"`
+		Length      int    `json:"length"`
+		Filename    string `json:"filename"`
+		ContentType string `json:"content_type,omitempty"`
+	}{
+		Type:        "attachment",
+		Length:      len(attachment.Payload),
+		Filename:    attachment.Filename,
+		ContentType: attachment.ContentType,
+	})
+	if err != nil {
+		return err
+	}
+
+	// Attachment payload
+	if _, err = b.Write(attachment.Payload); err != nil {
+		return err
+	}
+
+	// "Envelopes should be terminated with a trailing newline."
+	//
+	// [1]: https://develop.sentry.dev/sdk/envelopes/#envelopes
+	if _, err := b.Write([]byte("\n")); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func encodeEnvelopeItem(enc *json.Encoder, itemType string, body json.RawMessage) error {
 	// Item header
 	err := enc.Encode(struct {
@@ -152,6 +184,13 @@ func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMes
 		return nil, err
 	}
 
+	// Attachments
+	for _, attachment := range event.attachments {
+		if err := encodeAttachment(enc, &b, attachment); err != nil {
+			return nil, err
+		}
+	}
+
 	// Profile data
 	if event.sdkMetaData.transactionProfile != nil {
 		body, err = json.Marshal(event.sdkMetaData.transactionProfile)



Test patch:
diff --git a/client_test.go b/client_test.go
index 2945043..9504e83 100644
--- a/client_test.go
+++ b/client_test.go
@@ -79,7 +79,7 @@ func TestCaptureMessageEmptyString(t *testing.T) {
 	}
 	got := transport.lastEvent
 	opts := cmp.Options{
-		cmpopts.IgnoreFields(Event{}, "sdkMetaData"),
+		cmpopts.IgnoreFields(Event{}, "sdkMetaData", "attachments"),
 		cmp.Transformer("SimplifiedEvent", func(e *Event) *Event {
 			return &Event{
 				Exception: e.Exception,
@@ -286,7 +286,7 @@ func TestCaptureEvent(t *testing.T) {
 		},
 	}
 	got := transport.lastEvent
-	opts := cmp.Options{cmpopts.IgnoreFields(Event{}, "Release", "sdkMetaData")}
+	opts := cmp.Options{cmpopts.IgnoreFields(Event{}, "Release", "sdkMetaData", "attachments")}
 	if diff := cmp.Diff(want, got, opts); diff != "" {
 		t.Errorf("Event mismatch (-want +got):\n%s", diff)
 	}
@@ -314,7 +314,7 @@ func TestCaptureEventNil(t *testing.T) {
 	}
 	got := transport.lastEvent
 	opts := cmp.Options{
-		cmpopts.IgnoreFields(Event{}, "sdkMetaData"),
+		cmpopts.IgnoreFields(Event{}, "sdkMetaData", "attachments"),
 		cmp.Transformer("SimplifiedEvent", func(e *Event) *Event {
 			return &Event{
 				Exception: e.Exception,
@@ -639,7 +639,7 @@ func TestRecover(t *testing.T) {
 		}
 		got := events[0]
 		opts := cmp.Options{
-			cmpopts.IgnoreFields(Event{}, "sdkMetaData"),
+			cmpopts.IgnoreFields(Event{}, "sdkMetaData", "attachments"),
 			cmp.Transformer("SimplifiedEvent", func(e *Event) *Event {
 				return &Event{
 					Message:   e.Message,
diff --git a/fasthttp/sentryfasthttp_test.go b/fasthttp/sentryfasthttp_test.go
index 6f40fd8..e77871d 100644
--- a/fasthttp/sentryfasthttp_test.go
+++ b/fasthttp/sentryfasthttp_test.go
@@ -207,7 +207,7 @@ func TestIntegration(t *testing.T) {
 			sentry.Event{},
 			"Contexts", "EventID", "Extra", "Platform", "Modules",
 			"Release", "Sdk", "ServerName", "Tags", "Timestamp",
-			"sdkMetaData",
+			"sdkMetaData", "attachments",
 		),
 		cmpopts.IgnoreMapEntries(func(k string, v string) bool {
 			// fasthttp changed Content-Length behavior in
diff --git a/gin/sentrygin_test.go b/gin/sentrygin_test.go
index 1d493cb..97d7332 100644
--- a/gin/sentrygin_test.go
+++ b/gin/sentrygin_test.go
@@ -330,7 +330,7 @@ func TestIntegration(t *testing.T) {
 			sentry.Event{},
 			"Contexts", "EventID", "Extra", "Platform", "Modules",
 			"Release", "Sdk", "ServerName", "Tags", "Timestamp",
-			"sdkMetaData",
+			"sdkMetaData", "attachments",
 		),
 		cmpopts.IgnoreFields(
 			sentry.Request{},
@@ -354,7 +354,7 @@ func TestIntegration(t *testing.T) {
 			sentry.Event{},
 			"Contexts", "EventID", "Platform", "Modules",
 			"Release", "Sdk", "ServerName", "Timestamp",
-			"sdkMetaData", "StartTime", "Spans",
+			"sdkMetaData", "StartTime", "Spans", "attachments",
 		),
 		cmpopts.IgnoreFields(
 			sentry.Request{},
diff --git a/http/sentryhttp_test.go b/http/sentryhttp_test.go
index 1d96561..dc6484f 100644
--- a/http/sentryhttp_test.go
+++ b/http/sentryhttp_test.go
@@ -210,7 +210,7 @@ func TestIntegration(t *testing.T) {
 			sentry.Event{},
 			"Contexts", "EventID", "Extra", "Platform", "Modules",
 			"Release", "Sdk", "ServerName", "Tags", "Timestamp",
-			"sdkMetaData",
+			"sdkMetaData", "attachments",
 		),
 		cmpopts.IgnoreFields(
 			sentry.Request{},
diff --git a/logrus/logrusentry_test.go b/logrus/logrusentry_test.go
index 2ffb67b..b206d5b 100644
--- a/logrus/logrusentry_test.go
+++ b/logrus/logrusentry_test.go
@@ -249,7 +249,7 @@ func Test_entryToEvent(t *testing.T) {
 			got := h.entryToEvent(tt.entry)
 			opts := cmp.Options{
 				cmpopts.IgnoreFields(sentry.Event{},
-					"sdkMetaData",
+					"sdkMetaData", "attachments",
 				),
 			}
 			if d := cmp.Diff(tt.want, got, opts); d != "" {
diff --git a/scope_concurrency_test.go b/scope_concurrency_test.go
index c003136..d4e56d4 100644
--- a/scope_concurrency_test.go
+++ b/scope_concurrency_test.go
@@ -54,6 +54,7 @@ func touchScope(scope *sentry.Scope, x int) {
 	scope.SetLevel(sentry.LevelDebug)
 	scope.SetFingerprint([]string{"foo"})
 	scope.AddBreadcrumb(&sentry.Breadcrumb{Message: "foo"}, 100)
+	scope.AddAttachment(&sentry.Attachment{Filename: "foo.txt"})
 	scope.SetUser(sentry.User{ID: "foo"})
 	scope.SetRequest(httptest.NewRequest("GET", "/foo", nil))
 
diff --git a/scope_test.go b/scope_test.go
index b2ca374..9656c18 100644
--- a/scope_test.go
+++ b/scope_test.go
@@ -13,6 +13,12 @@ var testNow = time.Now().UTC()
 
 func fillScopeWithData(scope *Scope) *Scope {
 	scope.breadcrumbs = []*Breadcrumb{{Timestamp: testNow, Message: "scopeBreadcrumbMessage"}}
+	scope.attachments = []*Attachment{
+		{
+			Filename: "scope-attachment.txt",
+			Payload:  []byte("Scope attachment contents."),
+		},
+	}
 	scope.user = User{ID: "1337"}
 	scope.tags = map[string]string{"scopeTagKey": "scopeTagValue"}
 	scope.contexts = map[string]Context{
@@ -28,6 +34,12 @@ func fillScopeWithData(scope *Scope) *Scope {
 
 func fillEventWithData(event *Event) *Event {
 	event.Breadcrumbs = []*Breadcrumb{{Timestamp: testNow, Message: "eventBreadcrumbMessage"}}
+	event.attachments = []*Attachment{
+		{
+			Filename: "event-attachment.txt",
+			Payload:  []byte("Event attachment contents."),
+		},
+	}
 	event.User = User{ID: "42"}
 	event.Tags = map[string]string{"eventTagKey": "eventTagValue"}
 	event.Contexts = map[string]Context{
@@ -357,6 +369,25 @@ func TestAddBreadcrumbAddsTimestamp(t *testing.T) {
 	}
 }
 
+func TestAddAttachmentAddsAttachment(t *testing.T) {
+	scope := NewScope()
+	scope.AddAttachment(&Attachment{Filename: "test.txt", Payload: []byte("Hello, World")})
+	assertEqual(t, []*Attachment{{Filename: "test.txt", Payload: []byte("Hello, World")}}, scope.attachments)
+}
+
+func TestAddAttachmentAppendsAttachment(t *testing.T) {
+	scope := NewScope()
+	scope.AddAttachment(&Attachment{Filename: "test1.txt", Payload: []byte("Hello, World!")})
+	scope.AddAttachment(&Attachment{Filename: "test2.txt", Payload: []byte("Hello, World?")})
+	scope.AddAttachment(&Attachment{Filename: "test3.txt", Payload: []byte("Hello, World.")})
+
+	assertEqual(t, []*Attachment{
+		{Filename: "test1.txt", Payload: []byte("Hello, World!")},
+		{Filename: "test2.txt", Payload: []byte("Hello, World?")},
+		{Filename: "test3.txt", Payload: []byte("Hello, World.")},
+	}, scope.attachments)
+}
+
 func TestScopeBasicInheritance(t *testing.T) {
 	scope := NewScope()
 	scope.SetExtra("a", 1)
@@ -381,6 +412,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	clone.SetLevel(LevelDebug)
 	clone.SetFingerprint([]string{"foo"})
 	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	clone.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	clone.SetUser(User{ID: "foo"})
 	r1 := httptest.NewRequest("GET", "/foo", nil)
 	clone.SetRequest(r1)
@@ -391,6 +423,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	scope.SetLevel(LevelFatal)
 	scope.SetFingerprint([]string{"bar"})
 	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, maxBreadcrumbs)
+	scope.AddAttachment(&Attachment{Filename: "bar.txt", Payload: []byte("bar")})
 	scope.SetUser(User{ID: "bar"})
 	r2 := httptest.NewRequest("GET", "/bar", nil)
 	scope.SetRequest(r2)
@@ -401,6 +434,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	assertEqual(t, LevelDebug, clone.level)
 	assertEqual(t, []string{"foo"}, clone.fingerprint)
 	assertEqual(t, []*Breadcrumb{{Timestamp: testNow, Message: "foo"}}, clone.breadcrumbs)
+	assertEqual(t, []*Attachment{{Filename: "foo.txt", Payload: []byte("foo")}}, clone.attachments)
 	assertEqual(t, User{ID: "foo"}, clone.user)
 	assertEqual(t, r1, clone.request)
 
@@ -410,6 +444,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	assertEqual(t, LevelFatal, scope.level)
 	assertEqual(t, []string{"bar"}, scope.fingerprint)
 	assertEqual(t, []*Breadcrumb{{Timestamp: testNow, Message: "bar"}}, scope.breadcrumbs)
+	assertEqual(t, []*Attachment{{Filename: "bar.txt", Payload: []byte("bar")}}, scope.attachments)
 	assertEqual(t, User{ID: "bar"}, scope.user)
 	assertEqual(t, r2, scope.request)
 }
@@ -423,6 +458,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	scope.SetLevel(LevelFatal)
 	scope.SetFingerprint([]string{"bar"})
 	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, maxBreadcrumbs)
+	scope.AddAttachment(&Attachment{Filename: "bar.txt", Payload: []byte("bar")})
 	scope.SetUser(User{ID: "bar"})
 	r1 := httptest.NewRequest("GET", "/bar", nil)
 	scope.SetRequest(r1)
@@ -437,6 +473,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	clone.SetLevel(LevelDebug)
 	clone.SetFingerprint([]string{"foo"})
 	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	clone.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	clone.SetUser(User{ID: "foo"})
 	r2 := httptest.NewRequest("GET", "/foo", nil)
 	clone.SetRequest(r2)
@@ -453,6 +490,10 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 		{Timestamp: testNow, Message: "bar"},
 		{Timestamp: testNow, Message: "foo"},
 	}, clone.breadcrumbs)
+	assertEqual(t, []*Attachment{
+		{Filename: "bar.txt", Payload: []byte("bar")},
+		{Filename: "foo.txt", Payload: []byte("foo")},
+	}, clone.attachments)
 	assertEqual(t, User{ID: "foo"}, clone.user)
 	assertEqual(t, r2, clone.request)
 
@@ -462,6 +503,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	assertEqual(t, LevelFatal, scope.level)
 	assertEqual(t, []string{"bar"}, scope.fingerprint)
 	assertEqual(t, []*Breadcrumb{{Timestamp: testNow, Message: "bar"}}, scope.breadcrumbs)
+	assertEqual(t, []*Attachment{{Filename: "bar.txt", Payload: []byte("bar")}}, scope.attachments)
 	assertEqual(t, User{ID: "bar"}, scope.user)
 	assertEqual(t, r1, scope.request)
 
@@ -474,6 +516,7 @@ func TestClear(t *testing.T) {
 	scope.Clear()
 
 	assertEqual(t, []*Breadcrumb{}, scope.breadcrumbs)
+	assertEqual(t, []*Attachment{}, scope.attachments)
 	assertEqual(t, User{}, scope.user)
 	assertEqual(t, map[string]string{}, scope.tags)
 	assertEqual(t, map[string]Context{}, scope.contexts)
@@ -493,6 +536,7 @@ func TestClearAndReconfigure(t *testing.T) {
 	scope.SetLevel(LevelDebug)
 	scope.SetFingerprint([]string{"foo"})
 	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	scope.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	scope.SetUser(User{ID: "foo"})
 	r := httptest.NewRequest("GET", "/foo", nil)
 	scope.SetRequest(r)
@@ -503,6 +547,7 @@ func TestClearAndReconfigure(t *testing.T) {
 	assertEqual(t, LevelDebug, scope.level)
 	assertEqual(t, []string{"foo"}, scope.fingerprint)
 	assertEqual(t, []*Breadcrumb{{Timestamp: testNow, Message: "foo"}}, scope.breadcrumbs)
+	assertEqual(t, []*Attachment{{Filename: "foo.txt", Payload: []byte("foo")}}, scope.attachments)
 	assertEqual(t, User{ID: "foo"}, scope.user)
 	assertEqual(t, r, scope.request)
 }
@@ -514,6 +559,13 @@ func TestClearBreadcrumbs(t *testing.T) {
 	assertEqual(t, []*Breadcrumb{}, scope.breadcrumbs)
 }
 
+func TestClearAttachments(t *testing.T) {
+	scope := fillScopeWithData(NewScope())
+	scope.ClearAttachments()
+
+	assertEqual(t, []*Attachment{}, scope.attachments)
+}
+
 func TestApplyToEventWithCorrectScopeAndEvent(t *testing.T) {
 	scope := fillScopeWithData(NewScope())
 	event := fillEventWithData(NewEvent())
@@ -521,6 +573,7 @@ func TestApplyToEventWithCorrectScopeAndEvent(t *testing.T) {
 	processedEvent := scope.ApplyToEvent(event, nil)
 
 	assertEqual(t, len(processedEvent.Breadcrumbs), 2, "should merge breadcrumbs")
+	assertEqual(t, len(processedEvent.attachments), 2, "should merge attachments")
 	assertEqual(t, len(processedEvent.Tags), 2, "should merge tags")
 	assertEqual(t, len(processedEvent.Contexts), 3, "should merge contexts")
 	assertEqual(t, event.Contexts[sharedContextsKey], event.Contexts[sharedContextsKey], "should not override event context")
@@ -538,6 +591,7 @@ func TestApplyToEventUsingEmptyScope(t *testing.T) {
 	processedEvent := scope.ApplyToEvent(event, nil)
 
 	assertEqual(t, len(processedEvent.Breadcrumbs), 1, "should use event breadcrumbs")
+	assertEqual(t, len(processedEvent.attachments), 1, "should use event attachments")
 	assertEqual(t, len(processedEvent.Tags), 1, "should use event tags")
 	assertEqual(t, len(processedEvent.Contexts), 2, "should use event contexts")
 	assertEqual(t, len(processedEvent.Extra), 1, "should use event extra")
@@ -554,6 +608,7 @@ func TestApplyToEventUsingEmptyEvent(t *testing.T) {
 	processedEvent := scope.ApplyToEvent(event, nil)
 
 	assertEqual(t, len(processedEvent.Breadcrumbs), 1, "should use scope breadcrumbs")
+	assertEqual(t, len(processedEvent.attachments), 1, "should use scope attachments")
 	assertEqual(t, len(processedEvent.Tags), 1, "should use scope tags")
 	assertEqual(t, len(processedEvent.Contexts), 2, "should use scope contexts")
 	assertEqual(t, len(processedEvent.Extra), 1, "should use scope extra")
diff --git a/tracing_test.go b/tracing_test.go
index 3fbd651..d31e517 100644
--- a/tracing_test.go
+++ b/tracing_test.go
@@ -157,7 +157,7 @@ func TestStartSpan(t *testing.T) {
 		cmpopts.IgnoreFields(Event{},
 			"Contexts", "EventID", "Level", "Platform",
 			"Release", "Sdk", "ServerName", "Modules",
-			"sdkMetaData",
+			"sdkMetaData", "attachments",
 		),
 		cmpopts.EquateEmpty(),
 	}
@@ -221,7 +221,7 @@ func TestStartChild(t *testing.T) {
 		cmpopts.IgnoreFields(Event{},
 			"EventID", "Level", "Platform", "Modules",
 			"Release", "Sdk", "ServerName", "Timestamp", "StartTime",
-			"sdkMetaData",
+			"sdkMetaData", "attachments",
 		),
 		cmpopts.IgnoreMapEntries(func(k string, v interface{}) bool {
 			return k != "trace"
@@ -302,7 +302,7 @@ func TestStartTransaction(t *testing.T) {
 		cmpopts.IgnoreFields(Event{},
 			"Contexts", "EventID", "Level", "Platform",
 			"Release", "Sdk", "ServerName", "Modules",
-			"sdkMetaData",
+			"sdkMetaData", "attachments",
 		),
 		cmpopts.EquateEmpty(),
 	}
diff --git a/transport_test.go b/transport_test.go
index 60d7f49..bdddc6a 100644
--- a/transport_test.go
+++ b/transport_test.go
@@ -191,6 +191,42 @@ func TestEnvelopeFromTransactionBody(t *testing.T) {
 	}
 }
 
+func TestEnvelopeFromEventWithAttachments(t *testing.T) {
+	event := newTestEvent(eventType)
+	event.attachments = []*Attachment{
+		// Empty content-type and payload
+		{
+			Filename: "empty.txt",
+		},
+		// Non-empty content-type and payload
+		{
+			Filename:    "non-empty.txt",
+			ContentType: "text/html",
+			Payload:     []byte("<h1>Look, HTML</h1>"),
+		},
+	}
+	sentAt := time.Unix(0, 0).UTC()
+
+	body := json.RawMessage(`{"type":"event","fields":"omitted"}`)
+
+	b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	got := b.String()
+	want := `{"event_id":"b81c5be4d31e48959103a1f878a1efcb","sent_at":"1970-01-01T00:00:00Z","dsn":"http://public@example.com/sentry/1","sdk":{"name":"sentry.go","version":"0.0.1"}}
+{"type":"event","length":35}
+{"type":"event","fields":"omitted"}
+{"type":"attachment","length":0,"filename":"empty.txt"}
+
+{"type":"attachment","length":19,"filename":"non-empty.txt","content_type":"text/html"}
+<h1>Look, HTML</h1>
+`
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("Envelope mismatch (-want +got):\n%s", diff)
+	}
+}
+
 func TestEnvelopeFromTransactionWithProfile(t *testing.T) {
 	event := newTestEvent(transactionType)
 	event.sdkMetaData.transactionProfile = &profileInfo{