diff --git a/dsn.go b/dsn.go index 36b9925a1..64b6f055d 100644 --- a/dsn.go +++ b/dsn.go @@ -1,233 +1,37 @@ package sentry import ( - "encoding/json" - "fmt" - "net/url" - "strconv" - "strings" - "time" + "github.com/getsentry/sentry-go/internal/protocol" ) -type scheme string +// Re-export protocol types to maintain public API compatibility -const ( - schemeHTTP scheme = "http" - schemeHTTPS scheme = "https" -) - -func (scheme scheme) defaultPort() int { - switch scheme { - case schemeHTTPS: - return 443 - case schemeHTTP: - return 80 - default: - return 80 - } +// Dsn is used as the remote address source to client transport. +type Dsn struct { + protocol.Dsn } // DsnParseError represents an error that occurs if a Sentry // DSN cannot be parsed. -type DsnParseError struct { - Message string -} - -func (e DsnParseError) Error() string { - return "[Sentry] DsnParseError: " + e.Message -} - -// Dsn is used as the remote address source to client transport. -type Dsn struct { - scheme scheme - publicKey string - secretKey string - host string - port int - path string - projectID string -} +type DsnParseError = protocol.DsnParseError // NewDsn creates a Dsn by parsing rawURL. Most users will never call this // function directly. It is provided for use in custom Transport // implementations. func NewDsn(rawURL string) (*Dsn, error) { - // Parse - parsedURL, err := url.Parse(rawURL) + protocolDsn, err := protocol.NewDsn(rawURL) if err != nil { - return nil, &DsnParseError{fmt.Sprintf("invalid url: %v", err)} - } - - // Scheme - var scheme scheme - switch parsedURL.Scheme { - case "http": - scheme = schemeHTTP - case "https": - scheme = schemeHTTPS - default: - return nil, &DsnParseError{"invalid scheme"} - } - - // PublicKey - publicKey := parsedURL.User.Username() - if publicKey == "" { - return nil, &DsnParseError{"empty username"} - } - - // SecretKey - var secretKey string - if parsedSecretKey, ok := parsedURL.User.Password(); ok { - secretKey = parsedSecretKey - } - - // Host - host := parsedURL.Hostname() - if host == "" { - return nil, &DsnParseError{"empty host"} - } - - // Port - var port int - if p := parsedURL.Port(); p != "" { - port, err = strconv.Atoi(p) - if err != nil { - return nil, &DsnParseError{"invalid port"} - } - } else { - port = scheme.defaultPort() - } - - // ProjectID - if parsedURL.Path == "" || parsedURL.Path == "/" { - return nil, &DsnParseError{"empty project id"} - } - pathSegments := strings.Split(parsedURL.Path[1:], "/") - projectID := pathSegments[len(pathSegments)-1] - - if projectID == "" { - return nil, &DsnParseError{"empty project id"} - } - - // Path - var path string - if len(pathSegments) > 1 { - path = "/" + strings.Join(pathSegments[0:len(pathSegments)-1], "/") - } - - return &Dsn{ - scheme: scheme, - publicKey: publicKey, - secretKey: secretKey, - host: host, - port: port, - path: path, - projectID: projectID, - }, nil -} - -// String formats Dsn struct into a valid string url. -func (dsn Dsn) String() string { - var url string - url += fmt.Sprintf("%s://%s", dsn.scheme, dsn.publicKey) - if dsn.secretKey != "" { - url += fmt.Sprintf(":%s", dsn.secretKey) - } - url += fmt.Sprintf("@%s", dsn.host) - if dsn.port != dsn.scheme.defaultPort() { - url += fmt.Sprintf(":%d", dsn.port) + return nil, err } - if dsn.path != "" { - url += dsn.path - } - url += fmt.Sprintf("/%s", dsn.projectID) - return url -} - -// Get the scheme of the DSN. -func (dsn Dsn) GetScheme() string { - return string(dsn.scheme) -} - -// Get the public key of the DSN. -func (dsn Dsn) GetPublicKey() string { - return dsn.publicKey -} - -// Get the secret key of the DSN. -func (dsn Dsn) GetSecretKey() string { - return dsn.secretKey -} - -// Get the host of the DSN. -func (dsn Dsn) GetHost() string { - return dsn.host -} - -// Get the port of the DSN. -func (dsn Dsn) GetPort() int { - return dsn.port -} - -// Get the path of the DSN. -func (dsn Dsn) GetPath() string { - return dsn.path + return &Dsn{Dsn: *protocolDsn}, nil } -// Get the project ID of the DSN. -func (dsn Dsn) GetProjectID() string { - return dsn.projectID -} - -// GetAPIURL returns the URL of the envelope endpoint of the project -// associated with the DSN. -func (dsn Dsn) GetAPIURL() *url.URL { - var rawURL string - rawURL += fmt.Sprintf("%s://%s", dsn.scheme, dsn.host) - if dsn.port != dsn.scheme.defaultPort() { - rawURL += fmt.Sprintf(":%d", dsn.port) - } - if dsn.path != "" { - rawURL += dsn.path - } - rawURL += fmt.Sprintf("/api/%s/%s/", dsn.projectID, "envelope") - parsedURL, _ := url.Parse(rawURL) - return parsedURL -} - -// RequestHeaders returns all the necessary headers that have to be used in the transport when seinding events +// RequestHeaders returns all the necessary headers that have to be used in the transport when sending events // to the /store endpoint. // // Deprecated: This method shall only be used if you want to implement your own transport that sends events to // the /store endpoint. If you're using the transport provided by the SDK, all necessary headers to authenticate // against the /envelope endpoint are added automatically. func (dsn Dsn) RequestHeaders() map[string]string { - auth := fmt.Sprintf("Sentry sentry_version=%s, sentry_timestamp=%d, "+ - "sentry_client=sentry.go/%s, sentry_key=%s", apiVersion, time.Now().Unix(), SDKVersion, dsn.publicKey) - - if dsn.secretKey != "" { - auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.secretKey) - } - - return map[string]string{ - "Content-Type": "application/json", - "X-Sentry-Auth": auth, - } -} - -// MarshalJSON converts the Dsn struct to JSON. -func (dsn Dsn) MarshalJSON() ([]byte, error) { - return json.Marshal(dsn.String()) -} - -// UnmarshalJSON converts JSON data to the Dsn struct. -func (dsn *Dsn) UnmarshalJSON(data []byte) error { - var str string - _ = json.Unmarshal(data, &str) - newDsn, err := NewDsn(str) - if err != nil { - return err - } - *dsn = *newDsn - return nil + return dsn.Dsn.RequestHeaders(SDKVersion) } diff --git a/dsn_test.go b/dsn_test.go index cd47d62fa..46c6f7afc 100644 --- a/dsn_test.go +++ b/dsn_test.go @@ -1,303 +1,81 @@ package sentry import ( - "encoding/json" - "regexp" - "strings" + "errors" "testing" - - "github.com/google/go-cmp/cmp" ) -type DsnTest struct { - in string - dsn *Dsn // expected value after parsing - url string // expected Store API URL - envURL string // expected Envelope API URL -} - -var dsnTests = map[string]DsnTest{ - "AllFields": { - in: "https://public:secret@domain:8888/foo/bar/42", - dsn: &Dsn{ - scheme: schemeHTTPS, - publicKey: "public", - secretKey: "secret", - host: "domain", - port: 8888, - path: "/foo/bar", - projectID: "42", - }, - url: "https://domain:8888/foo/bar/api/42/store/", - envURL: "https://domain:8888/foo/bar/api/42/envelope/", - }, - "MinimalSecure": { - in: "https://public@domain/42", - dsn: &Dsn{ - scheme: schemeHTTPS, - publicKey: "public", - host: "domain", - port: 443, - projectID: "42", - }, - url: "https://domain/api/42/store/", - envURL: "https://domain/api/42/envelope/", - }, - "MinimalInsecure": { - in: "http://public@domain/42", - dsn: &Dsn{ - scheme: schemeHTTP, - publicKey: "public", - host: "domain", - port: 80, - projectID: "42", - }, - url: "http://domain/api/42/store/", - envURL: "http://domain/api/42/envelope/", - }, -} - -// nolint: scopelint // false positive https://github.com/kyoh86/scopelint/issues/4 -func TestNewDsn(t *testing.T) { - for name, tt := range dsnTests { - t.Run(name, func(t *testing.T) { - dsn, err := NewDsn(tt.in) - if err != nil { - t.Fatalf("NewDsn() error: %q", err) - } - // Internal fields - if diff := cmp.Diff(tt.dsn, dsn, cmp.AllowUnexported(Dsn{})); diff != "" { - t.Errorf("NewDsn() mismatch (-want +got):\n%s", diff) - } - url := dsn.GetAPIURL().String() - if diff := cmp.Diff(tt.envURL, url); diff != "" { - t.Errorf("dsn.EnvelopeAPIURL() mismatch (-want +got):\n%s", diff) - } - }) - } -} - -type invalidDsnTest struct { - in string - err string // expected substring of the error -} - -var invalidDsnTests = map[string]invalidDsnTest{ - "Empty": {"", "invalid scheme"}, - "NoScheme1": {"public:secret@:8888/42", "invalid scheme"}, - // FIXME: NoScheme2's error message is inconsistent with NoScheme1; consider - // avoiding leaking errors from url.Parse. - "NoScheme2": {"://public:secret@:8888/42", "missing protocol scheme"}, - "NoPublicKey": {"https://:secret@domain:8888/42", "empty username"}, - "NoHost": {"https://public:secret@:8888/42", "empty host"}, - "NoProjectID1": {"https://public:secret@domain:8888/", "empty project id"}, - "NoProjectID2": {"https://public:secret@domain:8888", "empty project id"}, - "BadURL": {"!@#$%^&*()", "invalid url"}, - "BadScheme": {"ftp://public:secret@domain:8888/1", "invalid scheme"}, - "BadPort": {"https://public:secret@domain:wat/42", "invalid port"}, - "TrailingSlash": {"https://public:secret@domain:8888/42/", "empty project id"}, -} - -// nolint: scopelint // false positive https://github.com/kyoh86/scopelint/issues/4 -func TestNewDsnInvalidInput(t *testing.T) { - for name, tt := range invalidDsnTests { - t.Run(name, func(t *testing.T) { - _, err := NewDsn(tt.in) - if err == nil { - t.Fatalf("got nil, want error with %q", tt.err) - } - if _, ok := err.(*DsnParseError); !ok { - t.Errorf("got %T, want %T", err, (*DsnParseError)(nil)) - } - if !strings.Contains(err.Error(), tt.err) { - t.Errorf("%q does not contain %q", err.Error(), tt.err) - } - }) - } -} - -func TestDsnSerializeDeserialize(t *testing.T) { - url := "https://public:secret@domain:8888/foo/bar/42" - dsn, dsnErr := NewDsn(url) - serialized, _ := json.Marshal(dsn) - var deserialized Dsn - unmarshalErr := json.Unmarshal(serialized, &deserialized) - - if unmarshalErr != nil { - t.Error("expected dsn unmarshal to not return error") - } - if dsnErr != nil { - t.Error("expected NewDsn to not return error") - } - assertEqual(t, `"https://public:secret@domain:8888/foo/bar/42"`, string(serialized)) - assertEqual(t, url, deserialized.String()) -} - -func TestDsnDeserializeInvalidJSON(t *testing.T) { - var invalidJSON Dsn - invalidJSONErr := json.Unmarshal([]byte(`"whoops`), &invalidJSON) - var invalidDsn Dsn - invalidDsnErr := json.Unmarshal([]byte(`"http://wat"`), &invalidDsn) - - if invalidJSONErr == nil { - t.Error("expected dsn unmarshal to return error") - } - if invalidDsnErr == nil { - t.Error("expected dsn unmarshal to return error") - } -} - -func TestRequestHeadersWithoutSecretKey(t *testing.T) { - url := "https://public@domain/42" - dsn, err := NewDsn(url) - if err != nil { - t.Fatal(err) - } - headers := dsn.RequestHeaders() - authRegexp := regexp.MustCompile("^Sentry sentry_version=7, sentry_timestamp=\\d+, " + - "sentry_client=sentry.go/.+, sentry_key=public$") - - if len(headers) != 2 { - t.Error("expected request to have 2 headers") - } - assertEqual(t, "application/json", headers["Content-Type"]) - if authRegexp.FindStringIndex(headers["X-Sentry-Auth"]) == nil { - t.Error("expected auth header to fulfill provided pattern") - } -} - -func TestRequestHeadersWithSecretKey(t *testing.T) { - url := "https://public:secret@domain/42" - dsn, err := NewDsn(url) - if err != nil { - t.Fatal(err) - } - headers := dsn.RequestHeaders() - authRegexp := regexp.MustCompile("^Sentry sentry_version=7, sentry_timestamp=\\d+, " + - "sentry_client=sentry.go/.+, sentry_key=public, sentry_secret=secret$") - - if len(headers) != 2 { - t.Error("expected request to have 2 headers") - } - assertEqual(t, "application/json", headers["Content-Type"]) - if authRegexp.FindStringIndex(headers["X-Sentry-Auth"]) == nil { - t.Error("expected auth header to fulfill provided pattern") - } -} - -func TestGetScheme(t *testing.T) { - tests := []struct { - dsn string - want string - }{ - {"http://public:secret@domain/42", "http"}, - {"https://public:secret@domain/42", "https"}, - } - for _, tt := range tests { - dsn, err := NewDsn(tt.dsn) +// TestDsn_Wrapper tests that the top-level Dsn wrapper works correctly. +func TestDsn_Wrapper(t *testing.T) { + t.Run("initialized DSN", func(t *testing.T) { + dsn, err := NewDsn("https://public:secret@example.com/1") if err != nil { - t.Fatal(err) + t.Fatalf("NewDsn() failed: %v", err) } - assertEqual(t, dsn.GetScheme(), tt.want) - } -} -func TestGetPublicKey(t *testing.T) { - tests := []struct { - dsn string - want string - }{ - {"https://public:secret@domain/42", "public"}, - } - for _, tt := range tests { - dsn, err := NewDsn(tt.dsn) - if err != nil { - t.Fatal(err) + // Test that all methods are accessible and return expected values + if dsn.String() == "" { + t.Error("String() returned empty") } - assertEqual(t, dsn.GetPublicKey(), tt.want) - } -} - -func TestGetSecretKey(t *testing.T) { - tests := []struct { - dsn string - want string - }{ - {"https://public:secret@domain/42", "secret"}, - {"https://public@domain/42", ""}, - } - for _, tt := range tests { - dsn, err := NewDsn(tt.dsn) - if err != nil { - t.Fatal(err) + if dsn.GetHost() != "example.com" { + t.Errorf("GetHost() = %s, want example.com", dsn.GetHost()) } - assertEqual(t, dsn.GetSecretKey(), tt.want) - } -} - -func TestGetHost(t *testing.T) { - tests := []struct { - dsn string - want string - }{ - {"http://public:secret@domain/42", "domain"}, - } - for _, tt := range tests { - dsn, err := NewDsn(tt.dsn) - if err != nil { - t.Fatal(err) + if dsn.GetPublicKey() != "public" { + t.Errorf("GetPublicKey() = %s, want public", dsn.GetPublicKey()) } - assertEqual(t, dsn.GetHost(), tt.want) - } -} - -func TestGetPort(t *testing.T) { - tests := []struct { - dsn string - want int - }{ - {"https://public:secret@domain/42", 443}, - {"http://public:secret@domain/42", 80}, - {"https://public:secret@domain:3000/42", 3000}, - } - for _, tt := range tests { - dsn, err := NewDsn(tt.dsn) - if err != nil { - t.Fatal(err) + if dsn.GetSecretKey() != "secret" { + t.Errorf("GetSecretKey() = %s, want secret", dsn.GetSecretKey()) } - assertEqual(t, dsn.GetPort(), tt.want) - } -} - -func TestGetPath(t *testing.T) { - tests := []struct { - dsn string - want string - }{ - {"https://public:secret@domain/42", ""}, - {"https://public:secret@domain/foo/bar/42", "/foo/bar"}, - } - for _, tt := range tests { - dsn, err := NewDsn(tt.dsn) - if err != nil { - t.Fatal(err) + if dsn.GetProjectID() != "1" { + t.Errorf("GetProjectID() = %s, want 1", dsn.GetProjectID()) + } + if dsn.GetScheme() != "https" { + t.Errorf("GetScheme() = %s, want https", dsn.GetScheme()) + } + if dsn.GetPort() != 443 { + t.Errorf("GetPort() = %d, want 443", dsn.GetPort()) + } + if dsn.GetPath() != "" { + t.Errorf("GetPath() = %s, want empty", dsn.GetPath()) + } + if dsn.GetAPIURL() == nil { + t.Error("GetAPIURL() returned nil") + } + if dsn.RequestHeaders() == nil { + t.Error("RequestHeaders() returned nil") + } + }) + + t.Run("empty DSN struct", func(t *testing.T) { + var dsn Dsn // Zero-value struct + + // Test that all methods work without panicking + // They should return empty/zero values for an uninitialized struct + _ = dsn.String() + _ = dsn.GetHost() + _ = dsn.GetPublicKey() + _ = dsn.GetSecretKey() + _ = dsn.GetProjectID() + _ = dsn.GetScheme() + _ = dsn.GetPort() + _ = dsn.GetPath() + _ = dsn.GetAPIURL() + _ = dsn.RequestHeaders() + + // If we get here without panicking, the test passes + t.Log("All methods executed without panic on empty DSN struct") + }) + + t.Run("NewDsn error handling", func(t *testing.T) { + _, err := NewDsn("invalid-dsn") + if err == nil { + t.Error("NewDsn() should return error for invalid DSN") } - assertEqual(t, dsn.GetPath(), tt.want) - } -} -func TestGetProjectID(t *testing.T) { - tests := []struct { - dsn string - want string - }{ - {"https://public:secret@domain/42", "42"}, - } - for _, tt := range tests { - dsn, err := NewDsn(tt.dsn) - if err != nil { - t.Fatal(err) + // Test that the error is the expected type + var dsnParseError *DsnParseError + if !errors.As(err, &dsnParseError) { + t.Errorf("Expected *DsnParseError, got %T", err) } - assertEqual(t, dsn.GetProjectID(), tt.want) - } + }) } diff --git a/dynamic_sampling_context.go b/dynamic_sampling_context.go index 8dae0838b..5ae38748e 100644 --- a/dynamic_sampling_context.go +++ b/dynamic_sampling_context.go @@ -60,7 +60,7 @@ func DynamicSamplingContextFromTransaction(span *Span) DynamicSamplingContext { } if dsn := client.dsn; dsn != nil { - if publicKey := dsn.publicKey; publicKey != "" { + if publicKey := dsn.GetPublicKey(); publicKey != "" { entries["public_key"] = publicKey } } @@ -136,7 +136,7 @@ func DynamicSamplingContextFromScope(scope *Scope, client *Client) DynamicSampli } if dsn := client.dsn; dsn != nil { - if publicKey := dsn.publicKey; publicKey != "" { + if publicKey := dsn.GetPublicKey(); publicKey != "" { entries["public_key"] = publicKey } } diff --git a/interfaces.go b/interfaces.go index 2cec1cca9..303450d70 100644 --- a/interfaces.go +++ b/interfaces.go @@ -13,6 +13,7 @@ import ( "time" "github.com/getsentry/sentry-go/attribute" + "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" ) @@ -41,18 +42,8 @@ const ( ) // SdkInfo contains all metadata about the SDK. -type SdkInfo struct { - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` - Integrations []string `json:"integrations,omitempty"` - Packages []SdkPackage `json:"packages,omitempty"` -} - -// SdkPackage describes a package that was installed. -type SdkPackage struct { - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` -} +type SdkInfo = protocol.SdkInfo +type SdkPackage = protocol.SdkPackage // TODO: This type could be more useful, as map of interface{} is too generic // and requires a lot of type assertions in beforeBreadcrumb calls @@ -249,11 +240,11 @@ var sensitiveHeaders = map[string]struct{}{ // NewRequest avoids operations that depend on network access. In particular, it // does not read r.Body. func NewRequest(r *http.Request) *Request { - protocol := schemeHTTP + prot := protocol.SchemeHTTP if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - protocol = schemeHTTPS + prot = protocol.SchemeHTTPS } - url := fmt.Sprintf("%s://%s%s", protocol, r.Host, r.URL.Path) + url := fmt.Sprintf("%s://%s%s", prot, r.Host, r.URL.Path) var cookies string var env map[string]string @@ -485,6 +476,78 @@ func (e *Event) SetException(exception error, maxErrorDepth int) { } } +// ToEnvelope converts the Event to a Sentry envelope. +// This includes the event data and any attachments as separate envelope items. +func (e *Event) ToEnvelope(dsn *protocol.Dsn) (*protocol.Envelope, error) { + return e.ToEnvelopeWithTime(dsn, time.Now()) +} + +// ToEnvelopeWithTime converts the Event to a Sentry envelope with a specific sentAt time. +// This is primarily useful for testing with predictable timestamps. +func (e *Event) ToEnvelopeWithTime(dsn *protocol.Dsn, sentAt time.Time) (*protocol.Envelope, error) { + // Create envelope header with trace context + trace := make(map[string]string) + if dsc := e.sdkMetaData.dsc; dsc.HasEntries() { + for k, v := range dsc.Entries { + trace[k] = v + } + } + + header := &protocol.EnvelopeHeader{ + EventID: string(e.EventID), + SentAt: sentAt, + Trace: trace, + } + + if dsn != nil { + header.Dsn = dsn.String() + } + + header.Sdk = &e.Sdk + + envelope := protocol.NewEnvelope(header) + + eventBody, err := json.Marshal(e) + if err != nil { + // Try fallback: remove problematic fields and retry + e.Breadcrumbs = nil + e.Contexts = nil + e.Extra = map[string]interface{}{ + "info": fmt.Sprintf("Could not encode original event as JSON. "+ + "Succeeded by removing Breadcrumbs, Contexts and Extra. "+ + "Please verify the data you attach to the scope. "+ + "Error: %s", err), + } + + eventBody, err = json.Marshal(e) + if err != nil { + return nil, fmt.Errorf("event could not be marshaled even with fallback: %w", err) + } + + DebugLogger.Printf("Event marshaling succeeded with fallback after removing problematic fields") + } + + var mainItem *protocol.EnvelopeItem + switch e.Type { + case transactionType: + mainItem = protocol.NewEnvelopeItem(protocol.EnvelopeItemTypeTransaction, eventBody) + case checkInType: + mainItem = protocol.NewEnvelopeItem(protocol.EnvelopeItemTypeCheckIn, eventBody) + case logEvent.Type: + mainItem = protocol.NewLogItem(len(e.Logs), eventBody) + default: + mainItem = protocol.NewEnvelopeItem(protocol.EnvelopeItemTypeEvent, eventBody) + } + + envelope.AddItem(mainItem) + for _, attachment := range e.Attachments { + attachmentItem := protocol.NewAttachmentItem(attachment.Filename, attachment.ContentType, attachment.Payload) + envelope.AddItem(attachmentItem) + } + + return envelope, nil +} + // TODO: Event.Contexts map[string]interface{} => map[string]EventContext, // to prevent accidentally storing T when we mean *T. // For example, the TraceContext must be stored as *TraceContext to pick up the diff --git a/interfaces_test.go b/interfaces_test.go index c9eeb2a49..0f20fbf18 100644 --- a/interfaces_test.go +++ b/interfaces_test.go @@ -1,6 +1,7 @@ package sentry import ( + "crypto/tls" "encoding/json" "errors" "flag" @@ -12,6 +13,7 @@ import ( "testing" "time" + "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" "github.com/google/go-cmp/cmp" ) @@ -34,6 +36,9 @@ func TestUserIsEmpty(t *testing.T) { {input: User{Name: "My Name"}, want: false}, {input: User{Data: map[string]string{"foo": "bar"}}, want: false}, {input: User{ID: "foo", Email: "foo@example.com", IPAddress: "127.0.0.1", Username: "My Username", Name: "My Name", Data: map[string]string{"foo": "bar"}}, want: false}, + // Edge cases + {input: User{Data: map[string]string{}}, want: true}, // Empty but non-nil map should be empty + {input: User{ID: " ", Username: " "}, want: false}, // Whitespace-only fields should not be empty } for _, test := range tests { @@ -74,39 +79,74 @@ func TestNewRequest(t *testing.T) { // Unbind the client afterwards, to not affect other tests defer currentHub.stackTop().SetClient(nil) - const payload = `{"test_data": true}` - r := httptest.NewRequest("POST", "/test/?q=sentry", strings.NewReader(payload)) - r.Header.Add("Authorization", "Bearer 1234567890") - r.Header.Add("Proxy-Authorization", "Bearer 123") - r.Header.Add("Cookie", "foo=bar") - r.Header.Add("X-Forwarded-For", "127.0.0.1") - r.Header.Add("X-Real-Ip", "127.0.0.1") - r.Header.Add("Some-Header", "some-header value") + t.Run("standard request", func(t *testing.T) { + const payload = `{"test_data": true}` + r := httptest.NewRequest("POST", "/test/?q=sentry", strings.NewReader(payload)) + r.Header.Add("Authorization", "Bearer 1234567890") + r.Header.Add("Proxy-Authorization", "Bearer 123") + r.Header.Add("Cookie", "foo=bar") + r.Header.Add("X-Forwarded-For", "127.0.0.1") + r.Header.Add("X-Real-Ip", "127.0.0.1") + r.Header.Add("Some-Header", "some-header value") + + got := NewRequest(r) + want := &Request{ + URL: "http://example.com/test/", + Method: "POST", + Data: "", + QueryString: "q=sentry", + Cookies: "foo=bar", + Headers: map[string]string{ + "Authorization": "Bearer 1234567890", + "Proxy-Authorization": "Bearer 123", + "Cookie": "foo=bar", + "Host": "example.com", + "X-Forwarded-For": "127.0.0.1", + "X-Real-Ip": "127.0.0.1", + "Some-Header": "some-header value", + }, + Env: map[string]string{ + "REMOTE_ADDR": "192.0.2.1", + "REMOTE_PORT": "1234", + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Request mismatch (-want +got):\n%s", diff) + } + }) - got := NewRequest(r) - want := &Request{ - URL: "http://example.com/test/", - Method: "POST", - Data: "", - QueryString: "q=sentry", - Cookies: "foo=bar", - Headers: map[string]string{ - "Authorization": "Bearer 1234567890", - "Proxy-Authorization": "Bearer 123", - "Cookie": "foo=bar", - "Host": "example.com", - "X-Forwarded-For": "127.0.0.1", - "X-Real-Ip": "127.0.0.1", - "Some-Header": "some-header value", - }, - Env: map[string]string{ - "REMOTE_ADDR": "192.0.2.1", - "REMOTE_PORT": "1234", - }, - } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("Request mismatch (-want +got):\n%s", diff) - } + t.Run("request with TLS", func(t *testing.T) { + r := httptest.NewRequest("POST", "https://example.com/test", nil) + r.TLS = &tls.ConnectionState{} // Simulate TLS connection + + got := NewRequest(r) + + if !strings.HasPrefix(got.URL, "https://") { + t.Errorf("Request with TLS should have HTTPS URL, got %s", got.URL) + } + }) + + t.Run("request with X-Forwarded-Proto header", func(t *testing.T) { + r := httptest.NewRequest("POST", "http://example.com/test", nil) + r.Header.Set("X-Forwarded-Proto", "https") + + got := NewRequest(r) + + if !strings.HasPrefix(got.URL, "https://") { + t.Errorf("Request with X-Forwarded-Proto: https should have HTTPS URL, got %s", got.URL) + } + }) + + t.Run("request with malformed RemoteAddr", func(t *testing.T) { + r := httptest.NewRequest("POST", "http://example.com/test", nil) + r.RemoteAddr = "malformed-address" // Invalid format + + got := NewRequest(r) + + if got.Env != nil { + t.Error("Request with malformed RemoteAddr should not set Env") + } + }) } func TestNewRequestWithNoPII(t *testing.T) { @@ -240,6 +280,11 @@ func TestSetException(t *testing.T) { maxErrorDepth int expected []Exception }{ + "Nil exception": { + exception: nil, + maxErrorDepth: 5, + expected: []Exception{}, + }, "Single error without unwrap": { exception: errors.New("simple error"), maxErrorDepth: 1, @@ -544,3 +589,222 @@ func TestEvent_ToCategory(t *testing.T) { }) } } + +func TestEvent_ToEnvelope(t *testing.T) { + tests := []struct { + name string + event *Event + dsn *protocol.Dsn + wantError bool + }{ + { + name: "basic event", + event: &Event{ + EventID: "12345678901234567890123456789012", + Message: "test message", + Level: LevelError, + Timestamp: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + }, + dsn: nil, + wantError: false, + }, + { + name: "event with attachments", + event: &Event{ + EventID: "12345678901234567890123456789012", + Message: "test message", + Level: LevelError, + Timestamp: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + Attachments: []*Attachment{ + { + Filename: "test.txt", + ContentType: "text/plain", + Payload: []byte("test content"), + }, + }, + }, + dsn: nil, + wantError: false, + }, + { + name: "transaction event", + event: &Event{ + EventID: "12345678901234567890123456789012", + Type: "transaction", + Transaction: "test transaction", + StartTime: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + Timestamp: time.Date(2023, 1, 1, 12, 0, 1, 0, time.UTC), + }, + dsn: nil, + wantError: false, + }, + { + name: "check-in event", + event: &Event{ + EventID: "12345678901234567890123456789012", + Type: "check_in", + CheckIn: &CheckIn{ + ID: "checkin123", + MonitorSlug: "test-monitor", + Status: CheckInStatusOK, + Duration: 5 * time.Second, + }, + }, + dsn: nil, + wantError: false, + }, + { + name: "log event", + event: &Event{ + EventID: "12345678901234567890123456789012", + Type: "log", + Logs: []Log{ + { + Timestamp: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + Level: LogLevelInfo, + Body: "test log message", + }, + }, + }, + dsn: nil, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envelope, err := tt.event.ToEnvelope(tt.dsn) + + if (err != nil) != tt.wantError { + t.Errorf("ToEnvelope() error = %v, wantError %v", err, tt.wantError) + return + } + + if err != nil { + return // Expected error, nothing more to check + } + + // Basic envelope validation + if envelope == nil { + t.Error("ToEnvelope() returned nil envelope") + return + } + + if envelope.Header == nil { + t.Error("Envelope header is nil") + return + } + + if envelope.Header.EventID != string(tt.event.EventID) { + t.Errorf("Expected EventID %s, got %s", tt.event.EventID, envelope.Header.EventID) + } + + // Check that items were created + expectedItems := 1 // Main event item + if tt.event.Attachments != nil { + expectedItems += len(tt.event.Attachments) + } + + if len(envelope.Items) != expectedItems { + t.Errorf("Expected %d items, got %d", expectedItems, len(envelope.Items)) + } + + // Verify the envelope can be serialized + data, err := envelope.Serialize() + if err != nil { + t.Errorf("Failed to serialize envelope: %v", err) + } + + if len(data) == 0 { + t.Error("Serialized envelope is empty") + } + }) + } +} + +func TestEvent_ToEnvelopeWithTime(t *testing.T) { + event := &Event{ + EventID: "12345678901234567890123456789012", + Message: "test message", + Level: LevelError, + Timestamp: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + } + + sentAt := time.Date(2023, 1, 1, 15, 0, 0, 0, time.UTC) + envelope, err := event.ToEnvelopeWithTime(nil, sentAt) + + if err != nil { + t.Errorf("ToEnvelopeWithTime() error = %v", err) + return + } + + if envelope == nil { + t.Error("ToEnvelopeWithTime() returned nil envelope") + return + } + + if envelope.Header == nil { + t.Error("Envelope header is nil") + return + } + + if !envelope.Header.SentAt.Equal(sentAt) { + t.Errorf("Expected SentAt %v, got %v", sentAt, envelope.Header.SentAt) + } +} + +func TestEvent_ToEnvelope_FallbackOnMarshalError(t *testing.T) { + unmarshalableFunc := func() string { return "test" } + + event := &Event{ + EventID: "12345678901234567890123456789012", + Message: "test message with fallback", + Level: LevelError, + Timestamp: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + Extra: map[string]interface{}{ + "bad_data": unmarshalableFunc, + }, + } + + envelope, err := event.ToEnvelope(nil) + + if err != nil { + t.Errorf("ToEnvelope() should not error even with unmarshalable data, got: %v", err) + return + } + + if envelope == nil { + t.Error("ToEnvelope() should not return a nil envelope") + return + } + + data, _ := envelope.Serialize() + + lines := strings.Split(string(data), "\n") + if len(lines) < 2 { + t.Error("Expected at least 2 lines in serialized envelope") + return + } + + var eventData map[string]interface{} + if err := json.Unmarshal([]byte(lines[2]), &eventData); err != nil { + t.Errorf("Failed to unmarshal event data: %v", err) + return + } + + extra, exists := eventData["extra"].(map[string]interface{}) + if !exists { + t.Error("Expected extra field after fallback") + return + } + + info, exists := extra["info"].(string) + if !exists { + t.Error("Expected info field in extra after fallback") + return + } + + if !strings.Contains(info, "Could not encode original event as JSON") { + t.Error("Expected fallback info message in extra field") + } +} diff --git a/internal/http/transport.go b/internal/http/transport.go new file mode 100644 index 000000000..52cb32b41 --- /dev/null +++ b/internal/http/transport.go @@ -0,0 +1,571 @@ +package http + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "sync/atomic" + "time" + + "github.com/getsentry/sentry-go/internal/debuglog" + "github.com/getsentry/sentry-go/internal/protocol" + "github.com/getsentry/sentry-go/internal/ratelimit" +) + +const ( + apiVersion = 7 + + defaultTimeout = time.Second * 30 + defaultQueueSize = 1000 + + // maxDrainResponseBytes is the maximum number of bytes that transport + // implementations will read from response bodies when draining them. + maxDrainResponseBytes = 16 << 10 +) + +var ( + ErrTransportQueueFull = errors.New("transport queue full") + ErrTransportClosed = errors.New("transport is closed") +) + +type TransportOptions struct { + Dsn string + HTTPClient *http.Client + HTTPTransport http.RoundTripper + HTTPProxy string + HTTPSProxy string + CaCerts *x509.CertPool +} + +func getProxyConfig(options TransportOptions) func(*http.Request) (*url.URL, error) { + if len(options.HTTPSProxy) > 0 { + return func(*http.Request) (*url.URL, error) { + return url.Parse(options.HTTPSProxy) + } + } + + if len(options.HTTPProxy) > 0 { + return func(*http.Request) (*url.URL, error) { + return url.Parse(options.HTTPProxy) + } + } + + return http.ProxyFromEnvironment +} + +func getTLSConfig(options TransportOptions) *tls.Config { + if options.CaCerts != nil { + return &tls.Config{ + RootCAs: options.CaCerts, + MinVersion: tls.VersionTLS12, + } + } + + return nil +} + +func getSentryRequestFromEnvelope(ctx context.Context, dsn *protocol.Dsn, envelope *protocol.Envelope) (r *http.Request, err error) { + defer func() { + if r != nil { + sdkName := envelope.Header.Sdk.Name + sdkVersion := envelope.Header.Sdk.Version + + r.Header.Set("User-Agent", fmt.Sprintf("%s/%s", sdkName, sdkVersion)) + r.Header.Set("Content-Type", "application/x-sentry-envelope") + + auth := fmt.Sprintf("Sentry sentry_version=%d, "+ + "sentry_client=%s/%s, sentry_key=%s", apiVersion, sdkName, sdkVersion, dsn.GetPublicKey()) + + if dsn.GetSecretKey() != "" { + auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.GetSecretKey()) + } + + r.Header.Set("X-Sentry-Auth", auth) + } + }() + + var buf bytes.Buffer + _, err = envelope.WriteTo(&buf) + if err != nil { + return nil, err + } + + return http.NewRequestWithContext( + ctx, + http.MethodPost, + dsn.GetAPIURL().String(), + &buf, + ) +} + +func categoryFromEnvelope(envelope *protocol.Envelope) ratelimit.Category { + if envelope == nil || len(envelope.Items) == 0 { + return ratelimit.CategoryAll + } + + for _, item := range envelope.Items { + if item == nil || item.Header == nil { + continue + } + + switch item.Header.Type { + case protocol.EnvelopeItemTypeEvent: + return ratelimit.CategoryError + case protocol.EnvelopeItemTypeTransaction: + return ratelimit.CategoryTransaction + case protocol.EnvelopeItemTypeCheckIn: + return ratelimit.CategoryMonitor + case protocol.EnvelopeItemTypeLog: + return ratelimit.CategoryLog + case protocol.EnvelopeItemTypeAttachment: + continue + default: + return ratelimit.CategoryAll + } + } + + return ratelimit.CategoryAll +} + +// SyncTransport is a blocking implementation of Transport. +// +// Clients using this transport will send requests to Sentry sequentially and +// block until a response is returned. +// +// The blocking behavior is useful in a limited set of use cases. For example, +// use it when deploying code to a Function as a Service ("Serverless") +// platform, where any work happening in a background goroutine is not +// guaranteed to execute. +// +// For most cases, prefer AsyncTransport. +type SyncTransport struct { + dsn *protocol.Dsn + client *http.Client + transport http.RoundTripper + + mu sync.Mutex + limits ratelimit.Map + + Timeout time.Duration +} + +func NewSyncTransport(options TransportOptions) protocol.TelemetryTransport { + dsn, err := protocol.NewDsn(options.Dsn) + if err != nil || dsn == nil { + debuglog.Printf("Transport is disabled: invalid dsn: %v\n", err) + return NewNoopTransport() + } + + transport := &SyncTransport{ + Timeout: defaultTimeout, + limits: make(ratelimit.Map), + dsn: dsn, + } + + if options.HTTPTransport != nil { + transport.transport = options.HTTPTransport + } else { + transport.transport = &http.Transport{ + Proxy: getProxyConfig(options), + TLSClientConfig: getTLSConfig(options), + } + } + + if options.HTTPClient != nil { + transport.client = options.HTTPClient + } else { + transport.client = &http.Client{ + Transport: transport.transport, + Timeout: transport.Timeout, + } + } + + return transport +} + +func (t *SyncTransport) SendEnvelope(envelope *protocol.Envelope) error { + return t.SendEnvelopeWithContext(context.Background(), envelope) +} + +func (t *SyncTransport) Close() {} + +func (t *SyncTransport) SendEvent(event protocol.EnvelopeConvertible) { + envelope, err := event.ToEnvelope(t.dsn) + if err != nil { + debuglog.Printf("Failed to convert to envelope: %v", err) + return + } + + if envelope == nil { + debuglog.Printf("Error: event with empty envelope") + return + } + + if err := t.SendEnvelope(envelope); err != nil { + debuglog.Printf("Error sending the envelope: %v", err) + } +} + +func (t *SyncTransport) IsRateLimited(category ratelimit.Category) bool { + return t.disabled(category) +} + +func (t *SyncTransport) SendEnvelopeWithContext(ctx context.Context, envelope *protocol.Envelope) error { + if envelope == nil { + debuglog.Printf("Error: provided empty envelope") + return nil + } + + category := categoryFromEnvelope(envelope) + if t.disabled(category) { + return nil + } + + request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) + if err != nil { + debuglog.Printf("There was an issue creating the request: %v", err) + return err + } + response, err := t.client.Do(request) + if err != nil { + debuglog.Printf("There was an issue with sending an event: %v", err) + return err + } + if response.StatusCode >= 400 && response.StatusCode <= 599 { + b, err := io.ReadAll(response.Body) + if err != nil { + debuglog.Printf("Error while reading response body: %v", err) + } + debuglog.Printf("Sending %s failed with the following error: %s", envelope.Header.EventID, string(b)) + } + + t.mu.Lock() + if t.limits == nil { + t.limits = make(ratelimit.Map) + } + + t.limits.Merge(ratelimit.FromResponse(response)) + t.mu.Unlock() + + _, _ = io.CopyN(io.Discard, response.Body, maxDrainResponseBytes) + return response.Body.Close() +} + +func (t *SyncTransport) Flush(_ time.Duration) bool { + return true +} + +func (t *SyncTransport) FlushWithContext(_ context.Context) bool { + return true +} + +func (t *SyncTransport) disabled(c ratelimit.Category) bool { + t.mu.Lock() + defer t.mu.Unlock() + disabled := t.limits.IsRateLimited(c) + if disabled { + debuglog.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c)) + } + return disabled +} + +// AsyncTransport is the default, non-blocking, implementation of Transport. +// +// Clients using this transport will enqueue requests in a queue and return to +// the caller before any network communication has happened. Requests are sent +// to Sentry sequentially from a background goroutine. +type AsyncTransport struct { + dsn *protocol.Dsn + client *http.Client + transport http.RoundTripper + + queue chan *protocol.Envelope + + mu sync.RWMutex + limits ratelimit.Map + + done chan struct{} + wg sync.WaitGroup + + flushRequest chan chan struct{} + + sentCount int64 + droppedCount int64 + errorCount int64 + + QueueSize int + Timeout time.Duration + + startOnce sync.Once + closeOnce sync.Once +} + +func NewAsyncTransport(options TransportOptions) protocol.TelemetryTransport { + dsn, err := protocol.NewDsn(options.Dsn) + if err != nil || dsn == nil { + debuglog.Printf("Transport is disabled: invalid dsn: %v", err) + return NewNoopTransport() + } + + transport := &AsyncTransport{ + QueueSize: defaultQueueSize, + Timeout: defaultTimeout, + done: make(chan struct{}), + limits: make(ratelimit.Map), + dsn: dsn, + } + + transport.queue = make(chan *protocol.Envelope, transport.QueueSize) + transport.flushRequest = make(chan chan struct{}) + + if options.HTTPTransport != nil { + transport.transport = options.HTTPTransport + } else { + transport.transport = &http.Transport{ + Proxy: getProxyConfig(options), + TLSClientConfig: getTLSConfig(options), + } + } + + if options.HTTPClient != nil { + transport.client = options.HTTPClient + } else { + transport.client = &http.Client{ + Transport: transport.transport, + Timeout: transport.Timeout, + } + } + + transport.start() + return transport +} + +func (t *AsyncTransport) start() { + t.startOnce.Do(func() { + t.wg.Add(1) + go t.worker() + }) +} + +func (t *AsyncTransport) SendEnvelope(envelope *protocol.Envelope) error { + select { + case <-t.done: + return ErrTransportClosed + default: + } + + category := categoryFromEnvelope(envelope) + if t.isRateLimited(category) { + return nil + } + + select { + case t.queue <- envelope: + return nil + default: + atomic.AddInt64(&t.droppedCount, 1) + return ErrTransportQueueFull + } +} + +func (t *AsyncTransport) SendEvent(event protocol.EnvelopeConvertible) { + envelope, err := event.ToEnvelope(t.dsn) + if err != nil { + debuglog.Printf("Failed to convert to envelope: %v", err) + return + } + + if envelope == nil { + debuglog.Printf("Error: event with empty envelope") + return + } + + if err := t.SendEnvelope(envelope); err != nil { + debuglog.Printf("Error sending the envelope: %v", err) + } +} + +func (t *AsyncTransport) Flush(timeout time.Duration) bool { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return t.FlushWithContext(ctx) +} + +func (t *AsyncTransport) FlushWithContext(ctx context.Context) bool { + flushResponse := make(chan struct{}) + select { + case t.flushRequest <- flushResponse: + select { + case <-flushResponse: + return true + case <-ctx.Done(): + return false + } + case <-ctx.Done(): + return false + } +} + +func (t *AsyncTransport) Close() { + t.closeOnce.Do(func() { + close(t.done) + close(t.queue) + close(t.flushRequest) + t.wg.Wait() + }) +} + +func (t *AsyncTransport) IsRateLimited(category ratelimit.Category) bool { + return t.isRateLimited(category) +} + +func (t *AsyncTransport) worker() { + defer t.wg.Done() + + for { + select { + case <-t.done: + return + case envelope, open := <-t.queue: + if !open { + return + } + t.processEnvelope(envelope) + case flushResponse, open := <-t.flushRequest: + if !open { + return + } + t.drainQueue() + close(flushResponse) + } + } +} + +func (t *AsyncTransport) drainQueue() { + for { + select { + case envelope, open := <-t.queue: + if !open { + return + } + t.processEnvelope(envelope) + default: + return + } + } +} + +func (t *AsyncTransport) processEnvelope(envelope *protocol.Envelope) { + if t.sendEnvelopeHTTP(envelope) { + atomic.AddInt64(&t.sentCount, 1) + } else { + atomic.AddInt64(&t.errorCount, 1) + } +} + +func (t *AsyncTransport) sendEnvelopeHTTP(envelope *protocol.Envelope) bool { + category := categoryFromEnvelope(envelope) + if t.isRateLimited(category) { + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + request, err := getSentryRequestFromEnvelope(ctx, t.dsn, envelope) + if err != nil { + debuglog.Printf("Failed to create request from envelope: %v", err) + return false + } + + response, err := t.client.Do(request) + if err != nil { + debuglog.Printf("HTTP request failed: %v", err) + return false + } + defer response.Body.Close() + + success := t.handleResponse(response) + + t.mu.Lock() + if t.limits == nil { + t.limits = make(ratelimit.Map) + } + t.limits.Merge(ratelimit.FromResponse(response)) + t.mu.Unlock() + + _, _ = io.CopyN(io.Discard, response.Body, maxDrainResponseBytes) + return success +} + +func (t *AsyncTransport) handleResponse(response *http.Response) bool { + if response.StatusCode >= 200 && response.StatusCode < 300 { + return true + } + + if response.StatusCode >= 400 && response.StatusCode < 500 { + if body, err := io.ReadAll(io.LimitReader(response.Body, maxDrainResponseBytes)); err == nil { + debuglog.Printf("Client error %d: %s", response.StatusCode, string(body)) + } + return false + } + + if response.StatusCode >= 500 { + debuglog.Printf("Server error %d", response.StatusCode) + return false + } + + debuglog.Printf("Unexpected status code %d", response.StatusCode) + return false +} + +func (t *AsyncTransport) isRateLimited(category ratelimit.Category) bool { + t.mu.RLock() + defer t.mu.RUnlock() + limited := t.limits.IsRateLimited(category) + if limited { + debuglog.Printf("Rate limited for category %q until %v", category, t.limits.Deadline(category)) + } + return limited +} + +// NoopTransport is a transport implementation that drops all events. +// Used internally when an empty or invalid DSN is provided. +type NoopTransport struct{} + +func NewNoopTransport() *NoopTransport { + debuglog.Println("Transport initialized with invalid DSN. Using NoopTransport. No events will be delivered.") + return &NoopTransport{} +} + +func (t *NoopTransport) SendEnvelope(_ *protocol.Envelope) error { + debuglog.Println("Envelope dropped due to NoopTransport usage.") + return nil +} + +func (t *NoopTransport) SendEvent(_ protocol.EnvelopeConvertible) { + debuglog.Println("Event dropped due to NoopTransport usage.") +} + +func (t *NoopTransport) IsRateLimited(_ ratelimit.Category) bool { + return false +} + +func (t *NoopTransport) Flush(_ time.Duration) bool { + return true +} + +func (t *NoopTransport) FlushWithContext(_ context.Context) bool { + return true +} + +func (t *NoopTransport) Close() { + // Nothing to close +} diff --git a/internal/http/transport_test.go b/internal/http/transport_test.go new file mode 100644 index 000000000..08c8ef55e --- /dev/null +++ b/internal/http/transport_test.go @@ -0,0 +1,824 @@ +package http + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/http/httptrace" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/getsentry/sentry-go/internal/protocol" + "github.com/getsentry/sentry-go/internal/ratelimit" + "github.com/getsentry/sentry-go/internal/testutils" + "go.uber.org/goleak" +) + +type mockEnvelopeConvertible struct { + envelope *protocol.Envelope + err error +} + +func (m *mockEnvelopeConvertible) ToEnvelope(_ *protocol.Dsn) (*protocol.Envelope, error) { + return m.envelope, m.err +} + +func testEnvelope(itemType protocol.EnvelopeItemType) *protocol.Envelope { + return &protocol.Envelope{ + Header: &protocol.EnvelopeHeader{ + EventID: "test-event-id", + Sdk: &protocol.SdkInfo{ + Name: "test", + Version: "1.0.0", + }, + }, + Items: []*protocol.EnvelopeItem{ + { + Header: &protocol.EnvelopeItemHeader{ + Type: itemType, + }, + Payload: []byte(`{"message": "test"}`), + }, + }, + } +} + +func TestAsyncTransport_SendEnvelope(t *testing.T) { + t.Run("invalid DSN", func(t *testing.T) { + transport := NewAsyncTransport(TransportOptions{}) + + if _, ok := transport.(*NoopTransport); !ok { + t.Errorf("expected NoopTransport for empty DSN, got %T", transport) + } + + err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + if err != nil { + t.Errorf("NoopTransport should not error, got %v", err) + } + }) + + t.Run("closed transport", func(t *testing.T) { + tr := NewAsyncTransport(TransportOptions{Dsn: "https://key@sentry.io/123"}) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport, got %T", tr) + } + transport.Close() + + err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + if !errors.Is(err, ErrTransportClosed) { + t.Errorf("expected ErrTransportClosed, got %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + tests := []struct { + name string + itemType protocol.EnvelopeItemType + }{ + {"event", protocol.EnvelopeItemTypeEvent}, + {"transaction", protocol.EnvelopeItemTypeTransaction}, + {"check-in", protocol.EnvelopeItemTypeCheckIn}, + {"log", protocol.EnvelopeItemTypeLog}, + {"attachment", protocol.EnvelopeItemTypeAttachment}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + tr := NewAsyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport, got %T", tr) + } + defer transport.Close() + + for _, tt := range tests { + if err := transport.SendEnvelope(testEnvelope(tt.itemType)); err != nil { + t.Errorf("send %s failed: %v", tt.name, err) + } + } + + if !transport.Flush(testutils.FlushTimeout()) { + t.Fatal("Flush timed out") + } + + expectedCount := int64(len(tests)) + if sent := atomic.LoadInt64(&transport.sentCount); sent != expectedCount { + t.Errorf("expected %d sent, got %d", expectedCount, sent) + } + }) + + t.Run("server error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + tr := NewAsyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport, got %T", tr) + } + defer transport.Close() + + if err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)); err != nil { + t.Fatalf("failed to send envelope: %v", err) + } + + if !transport.Flush(testutils.FlushTimeout()) { + t.Fatal("Flush timed out") + } + + if sent := atomic.LoadInt64(&transport.sentCount); sent != 0 { + t.Errorf("expected 0 sent, got %d", sent) + } + if errors := atomic.LoadInt64(&transport.errorCount); errors != 1 { + t.Errorf("expected 1 error, got %d", errors) + } + }) + + t.Run("rate limiting by category", func(t *testing.T) { + var count int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if atomic.AddInt64(&count, 1) == 1 { + w.Header().Add("X-Sentry-Rate-Limits", "60:error,60:transaction") + w.WriteHeader(http.StatusTooManyRequests) + } else { + w.WriteHeader(http.StatusOK) + } + })) + defer server.Close() + + tr := NewAsyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport, got %T", tr) + } + defer transport.Close() + + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + if !transport.Flush(testutils.FlushTimeout()) { + t.Fatal("Flush timed out") + } + + if !transport.IsRateLimited(ratelimit.CategoryError) { + t.Error("error category should be rate limited") + } + if !transport.IsRateLimited(ratelimit.CategoryTransaction) { + t.Error("transaction category should be rate limited") + } + if transport.IsRateLimited(ratelimit.CategoryMonitor) { + t.Error("monitor category should not be rate limited") + } + + for i := 0; i < 2; i++ { + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + } + if !transport.Flush(testutils.FlushTimeout()) { + t.Fatal("Flush timed out") + } + }) + + t.Run("queue overflow", func(t *testing.T) { + blockChan := make(chan struct{}) + requestReceived := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + select { + case requestReceived <- struct{}{}: + default: + } + <-blockChan + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + dsn, _ := protocol.NewDsn("http://key@" + server.URL[7:] + "/123") + transport := &AsyncTransport{ + QueueSize: 2, + Timeout: defaultTimeout, + done: make(chan struct{}), + limits: make(ratelimit.Map), + dsn: dsn, + transport: &http.Transport{}, + client: &http.Client{Timeout: defaultTimeout}, + } + // manually set the queue size to simulate overflow + transport.queue = make(chan *protocol.Envelope, transport.QueueSize) + transport.flushRequest = make(chan chan struct{}) + transport.start() + defer func() { + close(blockChan) + transport.Close() + }() + + if err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)); err != nil { + t.Fatalf("first send should succeed: %v", err) + } + + <-requestReceived + + for i := 0; i < transport.QueueSize; i++ { + if err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)); err != nil { + t.Errorf("send %d should succeed: %v", i, err) + } + } + + err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + if !errors.Is(err, ErrTransportQueueFull) { + t.Errorf("expected ErrTransportQueueFull, got %v", err) + } + }) +} + +func TestAsyncTransport_SendEvent(t *testing.T) { + tests := []struct { + name string + event *mockEnvelopeConvertible + }{ + { + name: "conversion error", + event: &mockEnvelopeConvertible{ + envelope: nil, + err: errors.New("conversion error"), + }, + }, + { + name: "nil envelope", + event: &mockEnvelopeConvertible{ + envelope: nil, + err: nil, + }, + }, + { + name: "success", + event: &mockEnvelopeConvertible{ + envelope: testEnvelope(protocol.EnvelopeItemTypeEvent), + err: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + tr := NewAsyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport, got %T", tr) + } + defer transport.Close() + + transport.SendEvent(tt.event) + + if tt.event.err == nil && tt.event.envelope != nil { + if !transport.Flush(testutils.FlushTimeout()) { + t.Fatal("Flush timed out") + } + } + }) + } +} + +func TestAsyncTransport_FlushWithContext(t *testing.T) { + t.Run("success", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + tr := NewAsyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport, got %T", tr) + } + defer transport.Close() + + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + + ctx := context.Background() + if !transport.FlushWithContext(ctx) { + t.Error("FlushWithContext should succeed") + } + }) + + t.Run("timeout", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + tr := NewAsyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport, got %T", tr) + } + defer transport.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + time.Sleep(10 * time.Millisecond) + + if transport.FlushWithContext(ctx) { + t.Error("FlushWithContext should timeout") + } + }) +} + +func TestAsyncTransport_Close(t *testing.T) { + tr := NewAsyncTransport(TransportOptions{ + Dsn: "https://key@sentry.io/123", + }) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport, got %T", tr) + } + + transport.Close() + transport.Close() + transport.Close() + + select { + case <-transport.done: + default: + t.Error("transport should be closed") + } +} + +func TestSyncTransport_SendEnvelope(t *testing.T) { + t.Run("invalid DSN", func(t *testing.T) { + transport := NewSyncTransport(TransportOptions{}) + err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + if err != nil { + t.Errorf("invalid DSN should return nil, got %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + tests := []struct { + name string + itemType protocol.EnvelopeItemType + }{ + {"event", protocol.EnvelopeItemTypeEvent}, + {"transaction", protocol.EnvelopeItemTypeTransaction}, + {"check-in", protocol.EnvelopeItemTypeCheckIn}, + {"log", protocol.EnvelopeItemTypeLog}, + {"attachment", protocol.EnvelopeItemTypeAttachment}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := NewSyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + defer transport.Close() + + for _, tt := range tests { + if err := transport.SendEnvelope(testEnvelope(tt.itemType)); err != nil { + t.Errorf("send %s failed: %v", tt.name, err) + } + } + }) + + t.Run("rate limited", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Add("X-Sentry-Rate-Limits", "60:error,60:transaction") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + + transport := NewSyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + + if !transport.IsRateLimited(ratelimit.CategoryError) { + t.Error("error category should be rate limited") + } + if !transport.IsRateLimited(ratelimit.CategoryTransaction) { + t.Error("transaction category should be rate limited") + } + if transport.IsRateLimited(ratelimit.CategoryMonitor) { + t.Error("monitor category should not be rate limited") + } + + err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + if err != nil { + t.Errorf("rate limited envelope should return nil, got %v", err) + } + }) + + t.Run("server error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal error")) + })) + defer server.Close() + + transport := NewSyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + + err := transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + if err != nil { + t.Errorf("server error should not return error, got %v", err) + } + }) +} + +func TestSyncTransport_SendEvent(t *testing.T) { + tests := []struct { + name string + event *mockEnvelopeConvertible + }{ + { + name: "conversion error", + event: &mockEnvelopeConvertible{ + envelope: nil, + err: errors.New("conversion error"), + }, + }, + { + name: "nil envelope", + event: &mockEnvelopeConvertible{ + envelope: nil, + err: nil, + }, + }, + { + name: "success", + event: &mockEnvelopeConvertible{ + envelope: testEnvelope(protocol.EnvelopeItemTypeEvent), + err: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(_ *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := NewSyncTransport(TransportOptions{ + Dsn: "http://key@" + server.URL[7:] + "/123", + }) + + transport.SendEvent(tt.event) + }) + } +} + +func TestSyncTransport_Flush(t *testing.T) { + transport := NewSyncTransport(TransportOptions{}) + + if !transport.Flush(testutils.FlushTimeout()) { + t.Error("Flush should always succeed") + } + + if !transport.FlushWithContext(context.Background()) { + t.Error("FlushWithContext should always succeed") + } +} + +type httptraceRoundTripper struct { + reusedConn []bool +} + +func (rt *httptraceRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + trace := &httptrace.ClientTrace{ + GotConn: func(connInfo httptrace.GotConnInfo) { + rt.reusedConn = append(rt.reusedConn, connInfo.Reused) + }, + } + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + return http.DefaultTransport.RoundTrip(req) +} + +func TestKeepAlive(t *testing.T) { + tests := []struct { + name string + async bool + }{ + {"AsyncTransport", true}, + {"SyncTransport", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + largeResponse := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintln(w, `{"id":"ec71d87189164e79ab1e61030c183af0"}`) + if largeResponse { + fmt.Fprintln(w, strings.Repeat(" ", maxDrainResponseBytes)) + } + })) + defer server.Close() + + rt := &httptraceRoundTripper{} + dsn := "http://key@" + server.URL[7:] + "/123" + + var transport interface { + SendEnvelope(*protocol.Envelope) error + Flush(time.Duration) bool + Close() + } + + if tt.async { + tr := NewAsyncTransport(TransportOptions{ + Dsn: dsn, + HTTPTransport: rt, + }) + asyncTransport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport") + } + defer asyncTransport.Close() + transport = asyncTransport + } else { + transport = NewSyncTransport(TransportOptions{ + Dsn: dsn, + HTTPTransport: rt, + }) + } + + reqCount := 0 + checkReuse := func(expected bool) { + t.Helper() + reqCount++ + if !transport.Flush(testutils.FlushTimeout()) { + t.Fatal("Flush timed out") + } + if len(rt.reusedConn) != reqCount { + t.Fatalf("got %d requests, want %d", len(rt.reusedConn), reqCount) + } + if rt.reusedConn[reqCount-1] != expected { + t.Fatalf("connection reuse = %v, want %v", rt.reusedConn[reqCount-1], expected) + } + } + + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + checkReuse(false) + + for i := 0; i < 3; i++ { + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + checkReuse(true) + } + + largeResponse = true + + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + checkReuse(true) + + for i := 0; i < 3; i++ { + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + checkReuse(false) + } + }) + } +} + +func TestConcurrentAccess(t *testing.T) { + tests := []struct { + name string + async bool + }{ + {"AsyncTransport", true}, + {"SyncTransport", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(_ *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + dsn := "http://key@" + server.URL[7:] + "/123" + + var transport interface { + SendEnvelope(*protocol.Envelope) error + Flush(time.Duration) bool + Close() + } + + if tt.async { + tr := NewAsyncTransport(TransportOptions{Dsn: dsn}) + asyncTransport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport") + } + defer asyncTransport.Close() + transport = asyncTransport + } else { + transport = NewSyncTransport(TransportOptions{Dsn: dsn}) + } + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 5; j++ { + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + } + }() + } + wg.Wait() + + transport.Flush(testutils.FlushTimeout()) + }) + } +} + +func TestTransportConfiguration(t *testing.T) { + tests := []struct { + name string + options TransportOptions + async bool + validate func(*testing.T, interface{}) + }{ + { + name: "HTTPProxy", + options: TransportOptions{ + Dsn: "https://key@sentry.io/123", + HTTPProxy: "http://proxy:8080", + }, + async: true, + validate: func(t *testing.T, tr interface{}) { + transport := tr.(*AsyncTransport) + httpTransport, ok := transport.transport.(*http.Transport) + if !ok { + t.Fatal("expected *http.Transport") + } + if httpTransport.Proxy == nil { + t.Fatal("expected proxy function") + } + + req, _ := http.NewRequest("GET", "https://example.com", nil) + proxyURL, err := httpTransport.Proxy(req) + if err != nil { + t.Fatalf("Proxy function error: %v", err) + } + if proxyURL == nil || proxyURL.String() != "http://proxy:8080" { + t.Errorf("expected proxy URL 'http://proxy:8080', got %v", proxyURL) + } + }, + }, + { + name: "HTTPSProxy", + options: TransportOptions{ + Dsn: "https://key@sentry.io/123", + HTTPSProxy: "https://secure-proxy:8443", + }, + async: true, + validate: func(t *testing.T, tr interface{}) { + transport := tr.(*AsyncTransport) + httpTransport, ok := transport.transport.(*http.Transport) + if !ok { + t.Fatal("expected *http.Transport") + } + + req, _ := http.NewRequest("GET", "https://example.com", nil) + proxyURL, err := httpTransport.Proxy(req) + if err != nil { + t.Fatalf("Proxy function error: %v", err) + } + if proxyURL == nil || proxyURL.String() != "https://secure-proxy:8443" { + t.Errorf("expected proxy URL 'https://secure-proxy:8443', got %v", proxyURL) + } + }, + }, + { + name: "CustomHTTPTransport", + options: TransportOptions{ + Dsn: "https://key@sentry.io/123", + HTTPTransport: &http.Transport{}, + HTTPProxy: "http://proxy:8080", + }, + async: true, + validate: func(t *testing.T, tr interface{}) { + transport := tr.(*AsyncTransport) + if transport.transport.(*http.Transport).Proxy != nil { + t.Error("custom transport should not have proxy from options") + } + }, + }, + { + name: "CaCerts", + options: TransportOptions{ + Dsn: "https://key@sentry.io/123", + CaCerts: x509.NewCertPool(), + }, + async: false, + validate: func(t *testing.T, tr interface{}) { + transport := tr.(*SyncTransport) + httpTransport, ok := transport.transport.(*http.Transport) + if !ok { + t.Fatal("expected *http.Transport") + } + if httpTransport.TLSClientConfig == nil { + t.Fatal("expected TLS config") + } + if httpTransport.TLSClientConfig.RootCAs == nil { + t.Error("expected custom certificate pool") + } + }, + }, + { + name: "AsyncTransport defaults", + options: TransportOptions{ + Dsn: "https://key@sentry.io/123", + }, + async: true, + validate: func(t *testing.T, tr interface{}) { + transport := tr.(*AsyncTransport) + if transport.QueueSize != defaultQueueSize { + t.Errorf("QueueSize = %d, want %d", transport.QueueSize, defaultQueueSize) + } + if transport.Timeout != defaultTimeout { + t.Errorf("Timeout = %v, want %v", transport.Timeout, defaultTimeout) + } + }, + }, + { + name: "SyncTransport defaults", + options: TransportOptions{ + Dsn: "https://key@sentry.io/123", + }, + async: false, + validate: func(t *testing.T, tr interface{}) { + transport := tr.(*SyncTransport) + if transport.Timeout != defaultTimeout { + t.Errorf("Timeout = %v, want %v", transport.Timeout, defaultTimeout) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.async { + transport := NewAsyncTransport(tt.options) + defer transport.Close() + tt.validate(t, transport) + } else { + transport := NewSyncTransport(tt.options) + tt.validate(t, transport) + } + }) + } +} + +func TestAsyncTransportDoesntLeakGoroutines(t *testing.T) { + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) + + tr := NewAsyncTransport(TransportOptions{ + Dsn: "https://test@foobar/1", + HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return nil, fmt.Errorf("mock transport") + }, + }, + }, + }) + transport, ok := tr.(*AsyncTransport) + if !ok { + t.Fatalf("expected *AsyncTransport") + } + + _ = transport.SendEnvelope(testEnvelope(protocol.EnvelopeItemTypeEvent)) + transport.Flush(testutils.FlushTimeout()) + transport.Close() +} diff --git a/internal/protocol/dsn.go b/internal/protocol/dsn.go new file mode 100644 index 000000000..42aff3142 --- /dev/null +++ b/internal/protocol/dsn.go @@ -0,0 +1,236 @@ +package protocol + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +// apiVersion is the version of the Sentry API. +const apiVersion = "7" + +type scheme string + +const ( + SchemeHTTP scheme = "http" + SchemeHTTPS scheme = "https" +) + +func (scheme scheme) defaultPort() int { + switch scheme { + case SchemeHTTPS: + return 443 + case SchemeHTTP: + return 80 + default: + return 80 + } +} + +// DsnParseError represents an error that occurs if a Sentry +// DSN cannot be parsed. +type DsnParseError struct { + Message string +} + +func (e DsnParseError) Error() string { + return "[Sentry] DsnParseError: " + e.Message +} + +// Dsn is used as the remote address source to client transport. +type Dsn struct { + scheme scheme + publicKey string + secretKey string + host string + port int + path string + projectID string +} + +// NewDsn creates a Dsn by parsing rawURL. Most users will never call this +// function directly. It is provided for use in custom Transport +// implementations. +func NewDsn(rawURL string) (*Dsn, error) { + // Parse + parsedURL, err := url.Parse(rawURL) + if err != nil { + return nil, &DsnParseError{fmt.Sprintf("invalid url: %v", err)} + } + + // Scheme + var scheme scheme + switch parsedURL.Scheme { + case "http": + scheme = SchemeHTTP + case "https": + scheme = SchemeHTTPS + default: + return nil, &DsnParseError{"invalid scheme"} + } + + // PublicKey + publicKey := parsedURL.User.Username() + if publicKey == "" { + return nil, &DsnParseError{"empty username"} + } + + // SecretKey + var secretKey string + if parsedSecretKey, ok := parsedURL.User.Password(); ok { + secretKey = parsedSecretKey + } + + // Host + host := parsedURL.Hostname() + if host == "" { + return nil, &DsnParseError{"empty host"} + } + + // Port + var port int + if p := parsedURL.Port(); p != "" { + port, err = strconv.Atoi(p) + if err != nil { + return nil, &DsnParseError{"invalid port"} + } + } else { + port = scheme.defaultPort() + } + + // ProjectID + if parsedURL.Path == "" || parsedURL.Path == "/" { + return nil, &DsnParseError{"empty project id"} + } + pathSegments := strings.Split(parsedURL.Path[1:], "/") + projectID := pathSegments[len(pathSegments)-1] + + if projectID == "" { + return nil, &DsnParseError{"empty project id"} + } + + // Path + var path string + if len(pathSegments) > 1 { + path = "/" + strings.Join(pathSegments[0:len(pathSegments)-1], "/") + } + + return &Dsn{ + scheme: scheme, + publicKey: publicKey, + secretKey: secretKey, + host: host, + port: port, + path: path, + projectID: projectID, + }, nil +} + +// String formats Dsn struct into a valid string url. +func (dsn Dsn) String() string { + var url string + url += fmt.Sprintf("%s://%s", dsn.scheme, dsn.publicKey) + if dsn.secretKey != "" { + url += fmt.Sprintf(":%s", dsn.secretKey) + } + url += fmt.Sprintf("@%s", dsn.host) + if dsn.port != dsn.scheme.defaultPort() { + url += fmt.Sprintf(":%d", dsn.port) + } + if dsn.path != "" { + url += dsn.path + } + url += fmt.Sprintf("/%s", dsn.projectID) + return url +} + +// Get the scheme of the DSN. +func (dsn Dsn) GetScheme() string { + return string(dsn.scheme) +} + +// Get the public key of the DSN. +func (dsn Dsn) GetPublicKey() string { + return dsn.publicKey +} + +// Get the secret key of the DSN. +func (dsn Dsn) GetSecretKey() string { + return dsn.secretKey +} + +// Get the host of the DSN. +func (dsn Dsn) GetHost() string { + return dsn.host +} + +// Get the port of the DSN. +func (dsn Dsn) GetPort() int { + return dsn.port +} + +// Get the path of the DSN. +func (dsn Dsn) GetPath() string { + return dsn.path +} + +// Get the project ID of the DSN. +func (dsn Dsn) GetProjectID() string { + return dsn.projectID +} + +// GetAPIURL returns the URL of the envelope endpoint of the project +// associated with the DSN. +func (dsn Dsn) GetAPIURL() *url.URL { + var rawURL string + rawURL += fmt.Sprintf("%s://%s", dsn.scheme, dsn.host) + if dsn.port != dsn.scheme.defaultPort() { + rawURL += fmt.Sprintf(":%d", dsn.port) + } + if dsn.path != "" { + rawURL += dsn.path + } + rawURL += fmt.Sprintf("/api/%s/%s/", dsn.projectID, "envelope") + parsedURL, _ := url.Parse(rawURL) + return parsedURL +} + +// RequestHeaders returns all the necessary headers that have to be used in the transport when sending events +// to the /store endpoint. +// +// Deprecated: This method shall only be used if you want to implement your own transport that sends events to +// the /store endpoint. If you're using the transport provided by the SDK, all necessary headers to authenticate +// against the /envelope endpoint are added automatically. +func (dsn Dsn) RequestHeaders(sdkVersion string) map[string]string { + auth := fmt.Sprintf("Sentry sentry_version=%s, sentry_timestamp=%d, "+ + "sentry_client=sentry.go/%s, sentry_key=%s", apiVersion, time.Now().Unix(), sdkVersion, dsn.publicKey) + + if dsn.secretKey != "" { + auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.secretKey) + } + + return map[string]string{ + "Content-Type": "application/json", + "X-Sentry-Auth": auth, + } +} + +// MarshalJSON converts the Dsn struct to JSON. +func (dsn Dsn) MarshalJSON() ([]byte, error) { + return json.Marshal(dsn.String()) +} + +// UnmarshalJSON converts JSON data to the Dsn struct. +func (dsn *Dsn) UnmarshalJSON(data []byte) error { + var str string + _ = json.Unmarshal(data, &str) + newDsn, err := NewDsn(str) + if err != nil { + return err + } + *dsn = *newDsn + return nil +} diff --git a/internal/protocol/dsn_test.go b/internal/protocol/dsn_test.go new file mode 100644 index 000000000..8d4fd965d --- /dev/null +++ b/internal/protocol/dsn_test.go @@ -0,0 +1,328 @@ +package protocol + +import ( + "encoding/json" + "errors" + "regexp" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +type DsnTest struct { + in string + dsn *Dsn // expected value after parsing + url string // expected Store API URL + envURL string // expected Envelope API URL +} + +var dsnTests = map[string]DsnTest{ + "AllFields": { + in: "https://public:secret@domain:8888/foo/bar/42", + dsn: &Dsn{ + scheme: SchemeHTTPS, + publicKey: "public", + secretKey: "secret", + host: "domain", + port: 8888, + path: "/foo/bar", + projectID: "42", + }, + url: "https://domain:8888/foo/bar/api/42/store/", + envURL: "https://domain:8888/foo/bar/api/42/envelope/", + }, + "MinimalSecure": { + in: "https://public@domain/42", + dsn: &Dsn{ + scheme: SchemeHTTPS, + publicKey: "public", + host: "domain", + port: 443, + projectID: "42", + }, + url: "https://domain/api/42/store/", + envURL: "https://domain/api/42/envelope/", + }, + "MinimalInsecure": { + in: "http://public@domain/42", + dsn: &Dsn{ + scheme: SchemeHTTP, + publicKey: "public", + host: "domain", + port: 80, + projectID: "42", + }, + url: "http://domain/api/42/store/", + envURL: "http://domain/api/42/envelope/", + }, +} + +// nolint: scopelint // false positive https://github.com/kyoh86/scopelint/issues/4 +func TestNewDsn(t *testing.T) { + for name, tt := range dsnTests { + t.Run(name, func(t *testing.T) { + dsn, err := NewDsn(tt.in) + if err != nil { + t.Fatalf("NewDsn() error: %q", err) + } + // Internal fields + if diff := cmp.Diff(tt.dsn, dsn, cmp.AllowUnexported(Dsn{})); diff != "" { + t.Errorf("NewDsn() mismatch (-want +got):\n%s", diff) + } + url := dsn.GetAPIURL().String() + if diff := cmp.Diff(tt.envURL, url); diff != "" { + t.Errorf("dsn.EnvelopeAPIURL() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +type invalidDsnTest struct { + in string + err string // expected substring of the error +} + +var invalidDsnTests = map[string]invalidDsnTest{ + "Empty": {"", "invalid scheme"}, + "NoScheme1": {"public:secret@:8888/42", "invalid scheme"}, + // FIXME: NoScheme2's error message is inconsistent with NoScheme1; consider + // avoiding leaking errors from url.Parse. + "NoScheme2": {"://public:secret@:8888/42", "missing protocol scheme"}, + "NoPublicKey": {"https://:secret@domain:8888/42", "empty username"}, + "NoHost": {"https://public:secret@:8888/42", "empty host"}, + "NoProjectID1": {"https://public:secret@domain:8888/", "empty project id"}, + "NoProjectID2": {"https://public:secret@domain:8888", "empty project id"}, + "BadURL": {"!@#$%^&*()", "invalid url"}, + "BadScheme": {"ftp://public:secret@domain:8888/1", "invalid scheme"}, + "BadPort": {"https://public:secret@domain:wat/42", "invalid port"}, + "TrailingSlash": {"https://public:secret@domain:8888/42/", "empty project id"}, +} + +// nolint: scopelint // false positive https://github.com/kyoh86/scopelint/issues/4 +func TestNewDsnInvalidInput(t *testing.T) { + for name, tt := range invalidDsnTests { + t.Run(name, func(t *testing.T) { + _, err := NewDsn(tt.in) + if err == nil { + t.Fatalf("got nil, want error with %q", tt.err) + } + var dsnParseError *DsnParseError + if !errors.As(err, &dsnParseError) { + t.Errorf("got %T, want %T", err, (*DsnParseError)(nil)) + } + if !strings.Contains(err.Error(), tt.err) { + t.Errorf("%q does not contain %q", err.Error(), tt.err) + } + }) + } +} + +func TestDsnSerializeDeserialize(t *testing.T) { + url := "https://public:secret@domain:8888/foo/bar/42" + dsn, dsnErr := NewDsn(url) + serialized, _ := json.Marshal(dsn) + var deserialized Dsn + unmarshalErr := json.Unmarshal(serialized, &deserialized) + + if unmarshalErr != nil { + t.Error("expected dsn unmarshal to not return error") + } + if dsnErr != nil { + t.Error("expected NewDsn to not return error") + } + expected := `"https://public:secret@domain:8888/foo/bar/42"` + if string(serialized) != expected { + t.Errorf("Expected %s, got %s", expected, string(serialized)) + } + if deserialized.String() != url { + t.Errorf("Expected %s, got %s", url, deserialized.String()) + } +} + +func TestDsnDeserializeInvalidJSON(t *testing.T) { + var invalidJSON Dsn + invalidJSONErr := json.Unmarshal([]byte(`"whoops`), &invalidJSON) + var invalidDsn Dsn + invalidDsnErr := json.Unmarshal([]byte(`"http://wat"`), &invalidDsn) + + if invalidJSONErr == nil { + t.Error("expected dsn unmarshal to return error") + } + if invalidDsnErr == nil { + t.Error("expected dsn unmarshal to return error") + } +} + +func TestRequestHeadersWithoutSecretKey(t *testing.T) { + url := "https://public@domain/42" + dsn, err := NewDsn(url) + if err != nil { + t.Fatal(err) + } + headers := dsn.RequestHeaders("sentry.go/1.0.0") + authRegexp := regexp.MustCompile("^Sentry sentry_version=7, sentry_timestamp=\\d+, " + + "sentry_client=sentry.go/.+, sentry_key=public$") + + if len(headers) != 2 { + t.Error("expected request to have 2 headers") + } + if headers["Content-Type"] != "application/json" { + t.Errorf("Expected Content-Type to be application/json, got %s", headers["Content-Type"]) + } + if authRegexp.FindStringIndex(headers["X-Sentry-Auth"]) == nil { + t.Error("expected auth header to fulfill provided pattern") + } +} + +func TestRequestHeadersWithSecretKey(t *testing.T) { + url := "https://public:secret@domain/42" + dsn, err := NewDsn(url) + if err != nil { + t.Fatal(err) + } + headers := dsn.RequestHeaders("sentry.go/1.0.0") + authRegexp := regexp.MustCompile("^Sentry sentry_version=7, sentry_timestamp=\\d+, " + + "sentry_client=sentry.go/.+, sentry_key=public, sentry_secret=secret$") + + if len(headers) != 2 { + t.Error("expected request to have 2 headers") + } + if headers["Content-Type"] != "application/json" { + t.Errorf("Expected Content-Type to be application/json, got %s", headers["Content-Type"]) + } + if authRegexp.FindStringIndex(headers["X-Sentry-Auth"]) == nil { + t.Error("expected auth header to fulfill provided pattern") + } +} + +func TestGetScheme(t *testing.T) { + tests := []struct { + dsn string + want string + }{ + {"http://public:secret@domain/42", "http"}, + {"https://public:secret@domain/42", "https"}, + } + for _, tt := range tests { + dsn, err := NewDsn(tt.dsn) + if err != nil { + t.Fatal(err) + } + if dsn.GetScheme() != tt.want { + t.Errorf("Expected scheme %s, got %s", tt.want, dsn.GetScheme()) + } + } +} + +func TestGetPublicKey(t *testing.T) { + tests := []struct { + dsn string + want string + }{ + {"https://public:secret@domain/42", "public"}, + } + for _, tt := range tests { + dsn, err := NewDsn(tt.dsn) + if err != nil { + t.Fatal(err) + } + if dsn.GetPublicKey() != tt.want { + t.Errorf("Expected public key %s, got %s", tt.want, dsn.GetPublicKey()) + } + } +} + +func TestGetSecretKey(t *testing.T) { + tests := []struct { + dsn string + want string + }{ + {"https://public:secret@domain/42", "secret"}, + {"https://public@domain/42", ""}, + } + for _, tt := range tests { + dsn, err := NewDsn(tt.dsn) + if err != nil { + t.Fatal(err) + } + if dsn.GetSecretKey() != tt.want { + t.Errorf("Expected secret key %s, got %s", tt.want, dsn.GetSecretKey()) + } + } +} + +func TestGetHost(t *testing.T) { + tests := []struct { + dsn string + want string + }{ + {"http://public:secret@domain/42", "domain"}, + } + for _, tt := range tests { + dsn, err := NewDsn(tt.dsn) + if err != nil { + t.Fatal(err) + } + if dsn.GetHost() != tt.want { + t.Errorf("Expected host %s, got %s", tt.want, dsn.GetHost()) + } + } +} + +func TestGetPort(t *testing.T) { + tests := []struct { + dsn string + want int + }{ + {"https://public:secret@domain/42", 443}, + {"http://public:secret@domain/42", 80}, + {"https://public:secret@domain:3000/42", 3000}, + } + for _, tt := range tests { + dsn, err := NewDsn(tt.dsn) + if err != nil { + t.Fatal(err) + } + if dsn.GetPort() != tt.want { + t.Errorf("Expected port %d, got %d", tt.want, dsn.GetPort()) + } + } +} + +func TestGetPath(t *testing.T) { + tests := []struct { + dsn string + want string + }{ + {"https://public:secret@domain/42", ""}, + {"https://public:secret@domain/foo/bar/42", "/foo/bar"}, + } + for _, tt := range tests { + dsn, err := NewDsn(tt.dsn) + if err != nil { + t.Fatal(err) + } + if dsn.GetPath() != tt.want { + t.Errorf("Expected path %s, got %s", tt.want, dsn.GetPath()) + } + } +} + +func TestGetProjectID(t *testing.T) { + tests := []struct { + dsn string + want string + }{ + {"https://public:secret@domain/42", "42"}, + } + for _, tt := range tests { + dsn, err := NewDsn(tt.dsn) + if err != nil { + t.Fatal(err) + } + if dsn.GetProjectID() != tt.want { + t.Errorf("Expected project ID %s, got %s", tt.want, dsn.GetProjectID()) + } + } +} diff --git a/internal/protocol/envelope.go b/internal/protocol/envelope.go new file mode 100644 index 000000000..65e305caf --- /dev/null +++ b/internal/protocol/envelope.go @@ -0,0 +1,213 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "time" +) + +// Envelope represents a Sentry envelope containing headers and items. +type Envelope struct { + Header *EnvelopeHeader `json:"-"` + Items []*EnvelopeItem `json:"-"` +} + +// EnvelopeHeader represents the header of a Sentry envelope. +type EnvelopeHeader struct { + // EventID is the unique identifier for this event + EventID string `json:"event_id"` + + // SentAt is the timestamp when the event was sent from the SDK as string in RFC 3339 format. + // Used for clock drift correction of the event timestamp. The time zone must be UTC. + SentAt time.Time `json:"sent_at,omitempty"` + + // Dsn can be used for self-authenticated envelopes. + // This means that the envelope has all the information necessary to be sent to sentry. + // In this case the full DSN must be stored in this key. + Dsn string `json:"dsn,omitempty"` + + // Sdk carries the same payload as the sdk interface in the event payload but can be carried for all events. + // This means that SDK information can be carried for minidumps, session data and other submissions. + Sdk *SdkInfo `json:"sdk,omitempty"` + + // Trace contains the [Dynamic Sampling Context](https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/) + Trace map[string]string `json:"trace,omitempty"` +} + +// EnvelopeItemType represents the type of envelope item. +type EnvelopeItemType string + +// Constants for envelope item types as defined in the Sentry documentation. +const ( + EnvelopeItemTypeEvent EnvelopeItemType = "event" + EnvelopeItemTypeTransaction EnvelopeItemType = "transaction" + EnvelopeItemTypeCheckIn EnvelopeItemType = "check_in" + EnvelopeItemTypeAttachment EnvelopeItemType = "attachment" + EnvelopeItemTypeLog EnvelopeItemType = "log" +) + +// EnvelopeItemHeader represents the header of an envelope item. +type EnvelopeItemHeader struct { + // Type specifies the type of this Item and its contents. + // Based on the Item type, more headers may be required. + Type EnvelopeItemType `json:"type"` + + // Length is the length of the payload in bytes. + // If no length is specified, the payload implicitly goes to the next newline. + // For payloads containing newline characters, the length must be specified. + Length *int `json:"length,omitempty"` + + // Filename is the name of the attachment file (used for attachments) + Filename string `json:"filename,omitempty"` + + // ContentType is the MIME type of the item payload (used for attachments and some other item types) + ContentType string `json:"content_type,omitempty"` + + // ItemCount is the number of items in a batch (used for logs) + ItemCount *int `json:"item_count,omitempty"` +} + +// EnvelopeItem represents a single item within an envelope. +type EnvelopeItem struct { + Header *EnvelopeItemHeader `json:"-"` + Payload []byte `json:"-"` +} + +// NewEnvelope creates a new envelope with the given header. +func NewEnvelope(header *EnvelopeHeader) *Envelope { + return &Envelope{ + Header: header, + Items: make([]*EnvelopeItem, 0), + } +} + +// AddItem adds an item to the envelope. +func (e *Envelope) AddItem(item *EnvelopeItem) { + e.Items = append(e.Items, item) +} + +// Serialize serializes the envelope to the Sentry envelope format. +// +// Format: Headers "\n" { Item } [ "\n" ] +// Item: Headers "\n" Payload "\n". +func (e *Envelope) Serialize() ([]byte, error) { + var buf bytes.Buffer + + headerBytes, err := json.Marshal(e.Header) + if err != nil { + return nil, fmt.Errorf("failed to marshal envelope header: %w", err) + } + + if _, err := buf.Write(headerBytes); err != nil { + return nil, fmt.Errorf("failed to write envelope header: %w", err) + } + + if _, err := buf.WriteString("\n"); err != nil { + return nil, fmt.Errorf("failed to write newline after envelope header: %w", err) + } + + for _, item := range e.Items { + if err := e.writeItem(&buf, item); err != nil { + return nil, fmt.Errorf("failed to write envelope item: %w", err) + } + } + + return buf.Bytes(), nil +} + +// WriteTo writes the envelope to the given writer in the Sentry envelope format. +func (e *Envelope) WriteTo(w io.Writer) (int64, error) { + data, err := e.Serialize() + if err != nil { + return 0, err + } + + n, err := w.Write(data) + return int64(n), err +} + +// writeItem writes a single envelope item to the buffer. +func (e *Envelope) writeItem(buf *bytes.Buffer, item *EnvelopeItem) error { + headerBytes, err := json.Marshal(item.Header) + if err != nil { + return fmt.Errorf("failed to marshal item header: %w", err) + } + + if _, err := buf.Write(headerBytes); err != nil { + return fmt.Errorf("failed to write item header: %w", err) + } + + if _, err := buf.WriteString("\n"); err != nil { + return fmt.Errorf("failed to write newline after item header: %w", err) + } + + if len(item.Payload) > 0 { + if _, err := buf.Write(item.Payload); err != nil { + return fmt.Errorf("failed to write item payload: %w", err) + } + } + + if _, err := buf.WriteString("\n"); err != nil { + return fmt.Errorf("failed to write newline after item payload: %w", err) + } + + return nil +} + +// Size returns the total size of the envelope when serialized. +func (e *Envelope) Size() (int, error) { + data, err := e.Serialize() + if err != nil { + return 0, err + } + return len(data), nil +} + +// MarshalJSON converts the EnvelopeHeader to JSON. +func (h *EnvelopeHeader) MarshalJSON() ([]byte, error) { + type header EnvelopeHeader + return json.Marshal((*header)(h)) +} + +// NewEnvelopeItem creates a new envelope item with the specified type and payload. +func NewEnvelopeItem(itemType EnvelopeItemType, payload []byte) *EnvelopeItem { + length := len(payload) + return &EnvelopeItem{ + Header: &EnvelopeItemHeader{ + Type: itemType, + Length: &length, + }, + Payload: payload, + } +} + +// NewAttachmentItem creates a new envelope item for an attachment. +// Parameters: filename, contentType, payload. +func NewAttachmentItem(filename, contentType string, payload []byte) *EnvelopeItem { + length := len(payload) + return &EnvelopeItem{ + Header: &EnvelopeItemHeader{ + Type: EnvelopeItemTypeAttachment, + Length: &length, + ContentType: contentType, + Filename: filename, + }, + Payload: payload, + } +} + +// NewLogItem creates a new envelope item for logs. +func NewLogItem(itemCount int, payload []byte) *EnvelopeItem { + length := len(payload) + return &EnvelopeItem{ + Header: &EnvelopeItemHeader{ + Type: EnvelopeItemTypeLog, + Length: &length, + ItemCount: &itemCount, + ContentType: "application/vnd.sentry.items.log+json", + }, + Payload: payload, + } +} diff --git a/internal/protocol/envelope_test.go b/internal/protocol/envelope_test.go new file mode 100644 index 000000000..dac63a5df --- /dev/null +++ b/internal/protocol/envelope_test.go @@ -0,0 +1,209 @@ +package protocol + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestEnvelope_ItemsAndSerialization(t *testing.T) { + tests := []struct { + name string + itemType EnvelopeItemType + payload []byte + creator func([]byte) *EnvelopeItem + }{ + { + name: "event", + itemType: EnvelopeItemTypeEvent, + payload: []byte(`{"message":"test event","level":"error"}`), + creator: func(p []byte) *EnvelopeItem { return NewEnvelopeItem(EnvelopeItemTypeEvent, p) }, + }, + { + name: "transaction", + itemType: EnvelopeItemTypeTransaction, + payload: []byte(`{"transaction":"test-transaction","type":"transaction"}`), + creator: func(p []byte) *EnvelopeItem { return NewEnvelopeItem(EnvelopeItemTypeTransaction, p) }, + }, + { + name: "check-in", + itemType: EnvelopeItemTypeCheckIn, + payload: []byte(`{"check_in_id":"abc123","monitor_slug":"test","status":"ok"}`), + creator: func(p []byte) *EnvelopeItem { return NewEnvelopeItem(EnvelopeItemTypeCheckIn, p) }, + }, + { + name: "attachment", + itemType: EnvelopeItemTypeAttachment, + payload: []byte("test attachment content"), + creator: func(p []byte) *EnvelopeItem { return NewAttachmentItem("test.txt", "text/plain", p) }, + }, + { + name: "log", + itemType: EnvelopeItemTypeLog, + payload: []byte(`[{"timestamp":"2023-01-01T12:00:00Z","level":"info","message":"test log"}]`), + creator: func(p []byte) *EnvelopeItem { return NewLogItem(1, p) }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := &EnvelopeHeader{ + EventID: "9ec79c33ec9942ab8353589fcb2e04dc", + SentAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + } + envelope := NewEnvelope(header) + item := tt.creator(tt.payload) + envelope.AddItem(item) + + data, err := envelope.Serialize() + if err != nil { + t.Fatalf("Serialize() failed for %s: %v", tt.name, err) + } + + lines := strings.Split(string(data), "\n") + if len(lines) < 3 { + t.Fatalf("Expected at least 3 lines for %s, got %d", tt.name, len(lines)) + } + + var envelopeHeader map[string]interface{} + if err := json.Unmarshal([]byte(lines[0]), &envelopeHeader); err != nil { + t.Fatalf("Failed to unmarshal envelope header: %v", err) + } + + var itemHeader map[string]interface{} + if err := json.Unmarshal([]byte(lines[1]), &itemHeader); err != nil { + t.Fatalf("Failed to unmarshal item header: %v", err) + } + + if itemHeader["type"] != string(tt.itemType) { + t.Errorf("Expected type %s, got %v", tt.itemType, itemHeader["type"]) + } + + if lines[2] != string(tt.payload) { + t.Errorf("Payload not preserved for %s", tt.name) + } + }) + } + + t.Run("multi-item envelope", func(t *testing.T) { + header := &EnvelopeHeader{ + EventID: "multi-test", + SentAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + } + envelope := NewEnvelope(header) + + envelope.AddItem(NewEnvelopeItem(EnvelopeItemTypeEvent, []byte(`{"message":"test"}`))) + envelope.AddItem(NewAttachmentItem("file.txt", "text/plain", []byte("content"))) + envelope.AddItem(NewLogItem(1, []byte(`[{"level":"info"}]`))) + + data, err := envelope.Serialize() + if err != nil { + t.Fatalf("Multi-item serialize failed: %v", err) + } + + if len(envelope.Items) != 3 { + t.Errorf("Expected 3 items, got %d", len(envelope.Items)) + } + + if len(data) == 0 { + t.Error("Serialized data is empty") + } + }) + + t.Run("empty envelope", func(t *testing.T) { + envelope := NewEnvelope(&EnvelopeHeader{EventID: "empty-test"}) + data, err := envelope.Serialize() + if err != nil { + t.Fatalf("Empty envelope serialize failed: %v", err) + } + if len(data) == 0 { + t.Error("Empty envelope should still produce header data") + } + }) +} + +func TestEnvelope_WriteTo(t *testing.T) { + header := &EnvelopeHeader{ + EventID: "12345678901234567890123456789012", + } + envelope := NewEnvelope(header) + envelope.AddItem(NewEnvelopeItem(EnvelopeItemTypeEvent, []byte(`{"test": true}`))) + + var buf bytes.Buffer + n, err := envelope.WriteTo(&buf) + + if err != nil { + t.Errorf("WriteTo() error = %v", err) + } + + if n <= 0 { + t.Errorf("Expected positive bytes written, got %d", n) + } + + expectedData, _ := envelope.Serialize() + if !bytes.Equal(buf.Bytes(), expectedData) { + t.Errorf("WriteTo() data differs from Serialize()") + } + + if int64(len(expectedData)) != n { + t.Errorf("WriteTo() returned %d bytes, but wrote %d bytes", n, len(expectedData)) + } +} + +func TestEnvelope_Size(t *testing.T) { + header := &EnvelopeHeader{EventID: "test"} + envelope := NewEnvelope(header) + + size1, err := envelope.Size() + if err != nil { + t.Errorf("Size() error = %v", err) + } + + envelope.AddItem(NewEnvelopeItem(EnvelopeItemTypeEvent, []byte(`{"test": true}`))) + size2, err := envelope.Size() + if err != nil { + t.Errorf("Size() error = %v", err) + } + + if size2 <= size1 { + t.Errorf("Expected size to increase after adding item, got %d -> %d", size1, size2) + } + + data, _ := envelope.Serialize() + if size2 != len(data) { + t.Errorf("Size() = %d, but Serialize() length = %d", size2, len(data)) + } +} + +func TestEnvelopeHeader_MarshalJSON(t *testing.T) { + header := &EnvelopeHeader{ + EventID: "12345678901234567890123456789012", + SentAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + Dsn: "https://public@example.com/1", + Trace: map[string]string{"trace_id": "abc123"}, + } + + data, err := header.MarshalJSON() + if err != nil { + t.Errorf("MarshalJSON() error = %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Errorf("Marshaled JSON is invalid: %v", err) + } + + if result["event_id"] != header.EventID { + t.Errorf("Expected event_id %s, got %v", header.EventID, result["event_id"]) + } + + if result["dsn"] != header.Dsn { + t.Errorf("Expected dsn %s, got %v", header.Dsn, result["dsn"]) + } + + if bytes.Contains(data, []byte("\n")) { + t.Error("Marshaled JSON contains newlines") + } +} diff --git a/internal/protocol/interfaces.go b/internal/protocol/interfaces.go new file mode 100644 index 000000000..6f6f29a7a --- /dev/null +++ b/internal/protocol/interfaces.go @@ -0,0 +1,40 @@ +package protocol + +import ( + "context" + "time" + + "github.com/getsentry/sentry-go/internal/ratelimit" +) + +// EnvelopeConvertible represents any type that can be converted to a Sentry envelope. +// This interface allows the telemetry buffers to be generic while still working with +// concrete types like Event. +type EnvelopeConvertible interface { + // ToEnvelope converts the item to a Sentry envelope. + ToEnvelope(dsn *Dsn) (*Envelope, error) +} + +// TelemetryTransport represents the envelope-first transport interface. +// This interface is designed for the telemetry buffer system and provides +// non-blocking sends with backpressure signals. +type TelemetryTransport interface { + // SendEnvelope sends an envelope to Sentry. Returns immediately with + // backpressure error if the queue is full. + SendEnvelope(envelope *Envelope) error + + // SendEvent sends an event to Sentry. + SendEvent(event EnvelopeConvertible) + + // IsRateLimited checks if a specific category is currently rate limited + IsRateLimited(category ratelimit.Category) bool + + // Flush waits for all pending envelopes to be sent, with timeout + Flush(timeout time.Duration) bool + + // FlushWithContext waits for all pending envelopes to be sent + FlushWithContext(ctx context.Context) bool + + // Close shuts down the transport gracefully + Close() +} diff --git a/internal/protocol/types.go b/internal/protocol/types.go new file mode 100644 index 000000000..5237c9ed1 --- /dev/null +++ b/internal/protocol/types.go @@ -0,0 +1,15 @@ +package protocol + +// SdkInfo contains SDK metadata. +type SdkInfo struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Integrations []string `json:"integrations,omitempty"` + Packages []SdkPackage `json:"packages,omitempty"` +} + +// SdkPackage describes a package that was installed. +type SdkPackage struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` +} diff --git a/transport.go b/transport.go index b2716c407..8e9d4fade 100644 --- a/transport.go +++ b/transport.go @@ -14,6 +14,8 @@ import ( "time" "github.com/getsentry/sentry-go/internal/debuglog" + httpinternal "github.com/getsentry/sentry-go/internal/http" + "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" ) @@ -228,13 +230,13 @@ func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn) (r *http.R r.Header.Set("Content-Type", "application/x-sentry-envelope") auth := fmt.Sprintf("Sentry sentry_version=%s, "+ - "sentry_client=%s/%s, sentry_key=%s", apiVersion, event.Sdk.Name, event.Sdk.Version, dsn.publicKey) + "sentry_client=%s/%s, sentry_key=%s", apiVersion, event.Sdk.Name, event.Sdk.Version, dsn.GetPublicKey()) // The key sentry_secret is effectively deprecated and no longer needs to be set. // However, since it was required in older self-hosted versions, // it should still passed through to Sentry if set. - if dsn.secretKey != "" { - auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.secretKey) + if dsn.GetSecretKey() != "" { + auth = fmt.Sprintf("%s, sentry_secret=%s", auth, dsn.GetSecretKey()) } r.Header.Set("X-Sentry-Auth", auth) @@ -410,8 +412,8 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event) "Sending %s [%s] to %s project: %s", eventType, event.EventID, - t.dsn.host, - t.dsn.projectID, + t.dsn.GetHost(), + t.dsn.GetProjectID(), ) default: debuglog.Println("Event dropped due to transport buffer being full.") @@ -665,8 +667,8 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve "Sending %s [%s] to %s project: %s", eventIdentifier, event.EventID, - t.dsn.host, - t.dsn.projectID, + t.dsn.GetHost(), + t.dsn.GetProjectID(), ) response, err := t.client.Do(request) @@ -743,3 +745,60 @@ func (noopTransport) FlushWithContext(context.Context) bool { } func (noopTransport) Close() {} + +// ================================ +// Internal Transport Adapters +// ================================ + +// NewInternalAsyncTransport creates a new AsyncTransport from internal/http +// wrapped to satisfy the Transport interface. +// +// This is not yet exposed in the public API and is for internal experimentation. +func NewInternalAsyncTransport() Transport { + return &internalAsyncTransportAdapter{} +} + +// internalAsyncTransportAdapter wraps the internal AsyncTransport to implement +// the root-level Transport interface. +type internalAsyncTransportAdapter struct { + transport protocol.TelemetryTransport + dsn *protocol.Dsn +} + +func (a *internalAsyncTransportAdapter) Configure(options ClientOptions) { + transportOptions := httpinternal.TransportOptions{ + Dsn: options.Dsn, + HTTPClient: options.HTTPClient, + HTTPTransport: options.HTTPTransport, + HTTPProxy: options.HTTPProxy, + HTTPSProxy: options.HTTPSProxy, + CaCerts: options.CaCerts, + } + + a.transport = httpinternal.NewAsyncTransport(transportOptions) + + if options.Dsn != "" { + dsn, err := protocol.NewDsn(options.Dsn) + if err != nil { + debuglog.Printf("Failed to parse DSN in adapter: %v\n", err) + } else { + a.dsn = dsn + } + } +} + +func (a *internalAsyncTransportAdapter) SendEvent(event *Event) { + a.transport.SendEvent(event) +} + +func (a *internalAsyncTransportAdapter) Flush(timeout time.Duration) bool { + return a.transport.Flush(timeout) +} + +func (a *internalAsyncTransportAdapter) FlushWithContext(ctx context.Context) bool { + return a.transport.FlushWithContext(ctx) +} + +func (a *internalAsyncTransportAdapter) Close() { + a.transport.Close() +} diff --git a/transport_test.go b/transport_test.go index f4a066ad2..b81ff829e 100644 --- a/transport_test.go +++ b/transport_test.go @@ -857,3 +857,41 @@ func TestHTTPSyncTransport_FlushWithContext(_ *testing.T) { tr := noopTransport{} tr.FlushWithContext(cancelCtx) } + +func TestInternalAsyncTransportAdapter(t *testing.T) { + transport := NewInternalAsyncTransport() + + transport.Configure(ClientOptions{ + Dsn: "", + }) + + event := NewEvent() + event.Message = "test message" + transport.SendEvent(event) + + if !transport.Flush(time.Second) { + t.Error("Flush should return true") + } + + if !transport.FlushWithContext(context.Background()) { + t.Error("FlushWithContext should return true") + } + + transport.Close() +} + +func TestInternalAsyncTransportAdapter_WithValidDSN(_ *testing.T) { + transport := NewInternalAsyncTransport() + + transport.Configure(ClientOptions{ + Dsn: "https://public@example.com/1", + }) + + event := NewEvent() + event.Message = "test message" + transport.SendEvent(event) + + transport.Flush(100 * time.Millisecond) + + transport.Close() +}