From 4f721459afc39b3eb38ee9035f933b70df7e6708 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Tue, 25 Jul 2023 17:49:51 +0400 Subject: [PATCH] feat: Attachments support (#670) --- client_test.go | 8 ++--- fasthttp/sentryfasthttp_test.go | 2 +- gin/sentrygin_test.go | 4 +-- http/sentryhttp_test.go | 2 +- interfaces.go | 9 ++++++ logrus/logrusentry_test.go | 2 +- scope.go | 24 ++++++++++++++ scope_concurrency_test.go | 1 + scope_test.go | 55 +++++++++++++++++++++++++++++++++ tracing_test.go | 6 ++-- transport.go | 39 +++++++++++++++++++++++ transport_test.go | 36 +++++++++++++++++++++ 12 files changed, 176 insertions(+), 12 deletions(-) diff --git a/client_test.go b/client_test.go index 29450430a..9504e8350 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 6f40fd86c..e77871d93 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 1d493cb0a..97d733214 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 1d965613d..dc6484f4f 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/interfaces.go b/interfaces.go index f87229474..497792887 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/logrus/logrusentry_test.go b/logrus/logrusentry_test.go index 2ffb67b8c..b206d5b85 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.go b/scope.go index 52cb76192..2f961bad8 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/scope_concurrency_test.go b/scope_concurrency_test.go index c00313687..d4e56d49c 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 b2ca37465..9656c1801 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 3fbd651d8..d31e51755 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.go b/transport.go index d8bdf2a74..ba5447264 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) diff --git a/transport_test.go b/transport_test.go index 60d7f49af..bdddc6a18 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("

Look, HTML

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

Look, HTML

+` + 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{