diff --git a/CHANGELOG.md b/CHANGELOG.md index 370963ac..f0eb748b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Features + +- Add net/http client integration ([#876](https://github.com/getsentry/sentry-go/pull/876)) + +### Bug Fixes + - Always set Mechanism Type to generic ([#896](https://github.com/getsentry/sentry-go/pull/897)) ## 0.29.1 diff --git a/_examples/httpclient/main.go b/_examples/httpclient/main.go new file mode 100644 index 00000000..0dfadb34 --- /dev/null +++ b/_examples/httpclient/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/getsentry/sentry-go" + sentryhttpclient "github.com/getsentry/sentry-go/httpclient" +) + +func main() { + _ = sentry.Init(sentry.ClientOptions{ + Dsn: "", + EnableTracing: true, + TracesSampleRate: 1.0, + BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + fmt.Println(event) + return event + }, + BeforeSendTransaction: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + fmt.Println(event) + return event + }, + Debug: true, + }) + + // With custom HTTP client + ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()) + httpClient := &http.Client{ + Transport: sentryhttpclient.NewSentryRoundTripper(nil), + } + + err := getExamplePage(ctx, httpClient) + if err != nil { + panic(err) + } + + // With Sentry's HTTP client + err = getExamplePage(ctx, sentryhttpclient.SentryHTTPClient) + if err != nil { + panic(err) + } +} + +func getExamplePage(ctx context.Context, httpClient *http.Client) error { + span := sentry.StartSpan(ctx, "getExamplePage") + ctx = span.Context() + defer span.Finish() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) + if err != nil { + return err + } + + response, err := httpClient.Do(request) + if err != nil { + return err + } + defer func() { + if response.Body != nil { + _ = response.Body.Close() + } + }() + + body, err := io.ReadAll(response.Body) + if err != nil { + return err + } + + fmt.Println(string(body)) + + return nil +} diff --git a/client.go b/client.go index b5b11b31..38f02b19 100644 --- a/client.go +++ b/client.go @@ -133,6 +133,10 @@ type ClientOptions struct { TracesSampleRate float64 // Used to customize the sampling of traces, overrides TracesSampleRate. TracesSampler TracesSampler + // Control with URLs trace propagation should be enabled. Does not support regex patterns. + TracePropagationTargets []string + // When set to true, the SDK will start a span for outgoing HTTP OPTIONS requests. + TraceOptionsRequests bool // The sample rate for profiling traces in the range [0.0, 1.0]. // This is relative to TracesSampleRate - it is a ratio of profiled traces out of all sampled traces. ProfilesSampleRate float64 diff --git a/httpclient/sentryhttpclient.go b/httpclient/sentryhttpclient.go new file mode 100644 index 00000000..e9d01ebc --- /dev/null +++ b/httpclient/sentryhttpclient.go @@ -0,0 +1,155 @@ +// Package sentryhttpclient provides Sentry integration for Requests modules to enable distributed tracing between services. +// It is compatible with `net/http.RoundTripper`. +// +// import sentryhttpclient "github.com/getsentry/sentry-go/httpclient" +// +// roundTrippper := sentryhttpclient.NewSentryRoundTripper(nil, nil) +// client := &http.Client{ +// Transport: roundTripper, +// } +// +// request, err := client.Do(request) +package sentryhttpclient + +import ( + "fmt" + "net/http" + "strings" + + "github.com/getsentry/sentry-go" +) + +// SentryRoundTripTracerOption provides a specific type in which defines the option for SentryRoundTripper. +type SentryRoundTripTracerOption func(*SentryRoundTripper) + +// WithTracePropagationTargets configures additional trace propagation targets URL for the RoundTripper. +// Does not support regex patterns. +func WithTracePropagationTargets(targets []string) SentryRoundTripTracerOption { + return func(t *SentryRoundTripper) { + if t.tracePropagationTargets == nil { + t.tracePropagationTargets = targets + } else { + t.tracePropagationTargets = append(t.tracePropagationTargets, targets...) + } + } +} + +// WithTraceOptionsRequests overrides the default options for whether the tracer should trace OPTIONS requests. +func WithTraceOptionsRequests(traceOptionsRequests bool) SentryRoundTripTracerOption { + return func(t *SentryRoundTripper) { + t.traceOptionsRequests = traceOptionsRequests + } +} + +// NewSentryRoundTripper provides a wrapper to existing http.RoundTripper to have required span data and trace headers for outgoing HTTP requests. +// +// - If `nil` is passed to `originalRoundTripper`, it will use http.DefaultTransport instead. +func NewSentryRoundTripper(originalRoundTripper http.RoundTripper, opts ...SentryRoundTripTracerOption) http.RoundTripper { + if originalRoundTripper == nil { + originalRoundTripper = http.DefaultTransport + } + + // Configure trace propagation targets + var tracePropagationTargets []string + var traceOptionsRequests bool + if hub := sentry.CurrentHub(); hub != nil { + client := hub.Client() + if client != nil { + clientOptions := client.Options() + if clientOptions.TracePropagationTargets != nil { + tracePropagationTargets = clientOptions.TracePropagationTargets + } + + traceOptionsRequests = clientOptions.TraceOptionsRequests + } + } + + t := &SentryRoundTripper{ + originalRoundTripper: originalRoundTripper, + tracePropagationTargets: tracePropagationTargets, + traceOptionsRequests: traceOptionsRequests, + } + + for _, opt := range opts { + if opt != nil { + opt(t) + } + } + + return t +} + +// SentryRoundTripper provides a http.RoundTripper implementation for Sentry Requests module. +type SentryRoundTripper struct { + originalRoundTripper http.RoundTripper + + tracePropagationTargets []string + traceOptionsRequests bool +} + +func (s *SentryRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + if request.Method == http.MethodOptions && !s.traceOptionsRequests { + return s.originalRoundTripper.RoundTrip(request) + } + + // Respect trace propagation targets + if len(s.tracePropagationTargets) > 0 { + requestURL := request.URL.String() + foundMatch := false + for _, target := range s.tracePropagationTargets { + if strings.Contains(requestURL, target) { + foundMatch = true + break + } + } + + if !foundMatch { + return s.originalRoundTripper.RoundTrip(request) + } + } + + // Only create the `http.client` span only if there is a parent span. + parentSpan := sentry.SpanFromContext(request.Context()) + if parentSpan == nil { + if hub := sentry.GetHubFromContext(request.Context()); hub != nil { + request.Header.Add("Baggage", hub.GetBaggage()) + request.Header.Add("Sentry-Trace", hub.GetTraceparent()) + } + + return s.originalRoundTripper.RoundTrip(request) + } + + cleanRequestURL := request.URL.Redacted() + + span := parentSpan.StartChild("http.client", sentry.WithTransactionName(fmt.Sprintf("%s %s", request.Method, cleanRequestURL))) + defer span.Finish() + + span.SetData("http.query", request.URL.Query().Encode()) + span.SetData("http.fragment", request.URL.Fragment) + span.SetData("http.request.method", request.Method) + span.SetData("server.address", request.URL.Hostname()) + span.SetData("server.port", request.URL.Port()) + + // Always add `Baggage` and `Sentry-Trace` headers. + request.Header.Add("Baggage", span.ToBaggage()) + request.Header.Add("Sentry-Trace", span.ToSentryTrace()) + + response, err := s.originalRoundTripper.RoundTrip(request) + if err != nil { + span.Status = sentry.SpanStatusInternalError + } + + if response != nil { + span.Status = sentry.HTTPtoSpanStatus(response.StatusCode) + span.SetData("http.response.status_code", response.StatusCode) + span.SetData("http.response_content_length", response.ContentLength) + } + + return response, err +} + +// SentryHTTPClient provides a default HTTP client with SentryRoundTripper included. +// This can be used directly to perform HTTP request. +var SentryHTTPClient = &http.Client{ + Transport: NewSentryRoundTripper(http.DefaultTransport), +} diff --git a/httpclient/sentryhttpclient_test.go b/httpclient/sentryhttpclient_test.go new file mode 100644 index 00000000..72a56182 --- /dev/null +++ b/httpclient/sentryhttpclient_test.go @@ -0,0 +1,501 @@ +package sentryhttpclient_test + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "errors" + "io" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/getsentry/sentry-go" + sentryhttpclient "github.com/getsentry/sentry-go/httpclient" + "github.com/getsentry/sentry-go/internal/testutils" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +type noopRoundTripper struct { + ExpectResponseStatus int + ExpectResponseLength int + ExpectError bool +} + +func (n *noopRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + if n.ExpectError { + return nil, errors.New("error") + } + + responseBody := make([]byte, n.ExpectResponseLength) + _, _ = rand.Read(responseBody) + return &http.Response{ + Status: "", + StatusCode: n.ExpectResponseStatus, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: map[string][]string{ + "Content-Length": {strconv.Itoa(len(responseBody))}, + }, + Body: io.NopCloser(bytes.NewReader(responseBody)), + ContentLength: int64(len(responseBody)), + TransferEncoding: []string{}, + Close: false, + Uncompressed: false, + Trailer: map[string][]string{}, + Request: request, + TLS: &tls.ConnectionState{}, + }, nil +} + +func TestIntegration(t *testing.T) { + tests := []struct { + RequestMethod string + RequestURL string + TracerOptions []sentryhttpclient.SentryRoundTripTracerOption + WantStatus int + WantResponseLength int + WantError bool + WantSpan *sentry.Span + }{ + { + RequestMethod: "GET", + RequestURL: "https://example.com/foo", + WantStatus: 200, + WantResponseLength: 0, + WantSpan: &sentry.Span{ + Data: map[string]interface{}{ + "http.fragment": string(""), + "http.query": string(""), + "http.request.method": string("GET"), + "http.response.status_code": int(200), + "http.response_content_length": int64(0), + "server.address": string("example.com"), + "server.port": string(""), + }, + Name: "GET https://example.com/foo", + Op: "http.client", + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusOK, + }, + }, + { + RequestMethod: "GET", + RequestURL: "https://example.com:443/foo/bar?baz=123#readme", + TracerOptions: []sentryhttpclient.SentryRoundTripTracerOption{nil, nil, nil}, + WantStatus: 200, + WantResponseLength: 0, + WantSpan: &sentry.Span{ + Data: map[string]interface{}{ + "http.fragment": string("readme"), + "http.query": string("baz=123"), + "http.request.method": string("GET"), + "http.response.status_code": int(200), + "http.response_content_length": int64(0), + "server.address": string("example.com"), + "server.port": string("443"), + }, + Name: "GET https://example.com:443/foo/bar?baz=123#readme", + Op: "http.client", + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusOK, + }, + }, + { + RequestMethod: "HEAD", + RequestURL: "https://example.com:8443/foo?bar=123&abc=def", + TracerOptions: []sentryhttpclient.SentryRoundTripTracerOption{}, + WantStatus: 400, + WantResponseLength: 0, + WantSpan: &sentry.Span{ + Data: map[string]interface{}{ + "http.fragment": string(""), + "http.query": string("abc=def&bar=123"), + "http.request.method": string("HEAD"), + "http.response.status_code": int(400), + "http.response_content_length": int64(0), + "server.address": string("example.com"), + "server.port": string("8443"), + }, + + Name: "HEAD https://example.com:8443/foo?bar=123&abc=def", + Op: "http.client", + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusInvalidArgument, + }, + }, + { + RequestMethod: "POST", + RequestURL: "https://john:verysecurepassword@example.com:4321/secret", + WantStatus: 200, + WantResponseLength: 1024, + WantSpan: &sentry.Span{ + Data: map[string]interface{}{ + "http.fragment": string(""), + "http.query": string(""), + "http.request.method": string("POST"), + "http.response.status_code": int(200), + "http.response_content_length": int64(1024), + "server.address": string("example.com"), + "server.port": string("4321"), + }, + Name: "POST https://john:xxxxx@example.com:4321/secret", + Op: "http.client", + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusOK, + }, + }, + { + RequestMethod: "POST", + RequestURL: "https://example.com", + WantError: true, + WantSpan: &sentry.Span{ + Data: map[string]interface{}{ + "http.fragment": string(""), + "http.query": string(""), + "http.request.method": string("POST"), + "server.address": string("example.com"), + "server.port": string(""), + }, + Name: "POST https://example.com", + Op: "http.client", + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusInternalError, + }, + }, + { + RequestMethod: "OPTIONS", + RequestURL: "https://example.com", + WantError: false, + WantSpan: nil, + }, + { + RequestMethod: "OPTIONS", + RequestURL: "https://example.com", + TracerOptions: []sentryhttpclient.SentryRoundTripTracerOption{sentryhttpclient.WithTraceOptionsRequests(true)}, + WantStatus: 204, + WantResponseLength: 0, + WantSpan: &sentry.Span{ + Data: map[string]interface{}{ + "http.fragment": string(""), + "http.query": string(""), + "http.request.method": string("OPTIONS"), + "http.response.status_code": int(204), + "http.response_content_length": int64(0), + "server.address": string("example.com"), + "server.port": string(""), + }, + + Name: "OPTIONS https://example.com", + Op: "http.client", + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusOK, + }, + }, + { + RequestMethod: "GET", + RequestURL: "https://example.com/foo/bar?baz=123#readme", + TracerOptions: []sentryhttpclient.SentryRoundTripTracerOption{sentryhttpclient.WithTracePropagationTargets([]string{"example.com"}), sentryhttpclient.WithTracePropagationTargets([]string{"example.org"})}, + WantStatus: 200, + WantResponseLength: 0, + WantSpan: &sentry.Span{ + Data: map[string]interface{}{ + "http.fragment": string("readme"), + "http.query": string("baz=123"), + "http.request.method": string("GET"), + "http.response.status_code": int(200), + "http.response_content_length": int64(0), + "server.address": string("example.com"), + "server.port": string(""), + }, + Name: "GET https://example.com/foo/bar?baz=123#readme", + Op: "http.client", + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusOK, + }, + }, + { + RequestMethod: "GET", + RequestURL: "https://example.net/foo/bar?baz=123#readme", + TracerOptions: []sentryhttpclient.SentryRoundTripTracerOption{sentryhttpclient.WithTracePropagationTargets([]string{"example.com"})}, + WantStatus: 200, + WantResponseLength: 0, + WantSpan: nil, + }, + } + + spansCh := make(chan []*sentry.Span, len(tests)) + + sentryClient, err := sentry.NewClient(sentry.ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + BeforeSendTransaction: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + spansCh <- event.Spans + return event + }, + }) + if err != nil { + t.Fatal(err) + } + + for _, tt := range tests { + hub := sentry.NewHub(sentryClient, sentry.NewScope()) + ctx := sentry.SetHubOnContext(context.Background(), hub) + span := sentry.StartSpan(ctx, "fake_parent", sentry.WithTransactionName("Fake Parent")) + ctx = span.Context() + + request, err := http.NewRequestWithContext(ctx, tt.RequestMethod, tt.RequestURL, nil) + if err != nil && !tt.WantError { + t.Fatal(err) + } + + roundTripper := &noopRoundTripper{ + ExpectResponseStatus: tt.WantStatus, + ExpectResponseLength: tt.WantResponseLength, + ExpectError: tt.WantError, + } + + client := &http.Client{ + Transport: sentryhttpclient.NewSentryRoundTripper(roundTripper, tt.TracerOptions...), + } + + response, err := client.Do(request) + if err != nil && !tt.WantError { + t.Fatal(err) + } + + if response != nil && response.Body != nil { + response.Body.Close() + } + span.Finish() + } + + if ok := sentryClient.Flush(testutils.FlushTimeout()); !ok { + t.Fatal("sentry.Flush timed out") + } + close(spansCh) + + var got [][]*sentry.Span + for e := range spansCh { + got = append(got, e) + } + + optstrans := cmp.Options{ + cmpopts.IgnoreFields( + sentry.Span{}, + "TraceID", "SpanID", "ParentSpanID", "StartTime", "EndTime", + "mu", "parent", "sampleRate", "ctx", "dynamicSamplingContext", "recorder", "finishOnce", "collectProfile", "contexts", + ), + } + for i, tt := range tests { + var foundMatch = false + gotSpans := got[i] + + var diffs []string + for _, gotSpan := range gotSpans { + if diff := cmp.Diff(tt.WantSpan, gotSpan, optstrans); diff != "" { + diffs = append(diffs, diff) + } else { + foundMatch = true + break + } + } + + if tt.WantSpan != nil && !foundMatch { + t.Errorf("Span mismatch (-want +got):\n%s", strings.Join(diffs, "\n")) + } else if tt.WantSpan == nil && foundMatch { + t.Errorf("Expected no span, got %+v", gotSpans) + } + } +} + +func TestIntegration_GlobalClientOptions(t *testing.T) { + spansCh := make(chan []*sentry.Span, 1) + + err := sentry.Init(sentry.ClientOptions{ + EnableTracing: true, + TracePropagationTargets: []string{"example.com"}, + TraceOptionsRequests: false, + TracesSampleRate: 1.0, + BeforeSendTransaction: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + spansCh <- event.Spans + return event + }, + }) + if err != nil { + t.Fatal(err) + } + + ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub()) + span := sentry.StartSpan(ctx, "fake_parent", sentry.WithTransactionName("Fake Parent")) + ctx = span.Context() + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://example.com", nil) + if err != nil { + t.Fatal(err) + } + + roundTripper := &noopRoundTripper{ + ExpectResponseStatus: 200, + ExpectResponseLength: 48, + ExpectError: false, + } + + client := &http.Client{ + Transport: sentryhttpclient.NewSentryRoundTripper(roundTripper), + } + + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + + if response != nil && response.Body != nil { + response.Body.Close() + } + span.Finish() + + if ok := sentry.Flush(testutils.FlushTimeout()); !ok { + t.Fatal("sentry.Flush timed out") + } + close(spansCh) + + var got []*sentry.Span + for e := range spansCh { + got = append(got, e...) + } + + optstrans := cmp.Options{ + cmpopts.IgnoreFields( + sentry.Span{}, + "TraceID", "SpanID", "ParentSpanID", "StartTime", "EndTime", + "mu", "parent", "sampleRate", "ctx", "dynamicSamplingContext", "recorder", "finishOnce", "collectProfile", "contexts", + ), + } + + gotSpan := got[0] + wantSpan := &sentry.Span{ + Data: map[string]interface{}{ + "http.fragment": string(""), + "http.query": string(""), + "http.request.method": string("POST"), + "http.response.status_code": int(200), + "http.response_content_length": int64(48), + "server.address": string("example.com"), + "server.port": string(""), + }, + Name: "POST https://example.com", + Op: "http.client", + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusOK, + } + + if diff := cmp.Diff(wantSpan, gotSpan, optstrans); diff != "" { + t.Errorf("Span mismatch (-want +got):\n%s", diff) + } +} + +func TestIntegration_NoParentSpan(t *testing.T) { + spansCh := make(chan []*sentry.Span, 1) + + sentryClient, err := sentry.NewClient(sentry.ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + BeforeSendTransaction: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + spansCh <- event.Spans + return event + }, + }) + if err != nil { + t.Fatal(err) + } + + hub := sentry.NewHub(sentryClient, sentry.NewScope()) + ctx := sentry.SetHubOnContext(context.Background(), hub) + + request, err := http.NewRequestWithContext(ctx, "GET", "https://example.com", nil) + if err != nil { + t.Fatal(err) + } + + roundTripper := &noopRoundTripper{ + ExpectResponseStatus: 200, + ExpectResponseLength: 0, + } + + client := &http.Client{ + Transport: sentryhttpclient.NewSentryRoundTripper(roundTripper), + } + + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + + response.Body.Close() + + if ok := sentryClient.Flush(testutils.FlushTimeout()); !ok { + t.Fatal("sentry.Flush timed out") + } + close(spansCh) + + var got [][]*sentry.Span + for e := range spansCh { + got = append(got, e) + } + + // Expect no spans. + if len(got) != 0 { + t.Errorf("Expected no spans, got %d", len(got)) + } + + // Expect "Baggage" and "Sentry-Trace" headers. + if value := response.Request.Header.Get("Baggage"); value != "" { + t.Errorf(`Expected "Baggage" header to be empty, got %s`, value) + } + + if value := response.Request.Header.Get("Sentry-Trace"); value == "" { + t.Errorf(`Expected "Sentry-Trace" header, got %s`, value) + } +} + +func TestDefaults(t *testing.T) { + t.Run("Create a regular outgoing HTTP request with default NewSentryRoundTripper", func(t *testing.T) { + roundTripper := sentryhttpclient.NewSentryRoundTripper(nil) + client := &http.Client{Transport: roundTripper} + + res, err := client.Head("https://sentry.io") + if err != nil { + t.Error(err) + } + + if res.Body != nil { + res.Body.Close() + } + }) + + t.Run("Create a regular outgoing HTTP request with default SentryHttpClient", func(t *testing.T) { + client := sentryhttpclient.SentryHTTPClient + + res, err := client.Head("https://sentry.io") + if err != nil { + t.Error(err) + } + + if res.Body != nil { + res.Body.Close() + } + }) +}