diff --git a/CHANGELOG.md b/CHANGELOG.md index dce9a3ff..1be932ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Add net/http client integration ([#876](https://github.com/getsentry/sentry-go/pull/876)) + ## 0.29.0 The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.29.0. 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/httpclient/sentryhttpclient.go b/httpclient/sentryhttpclient.go new file mode 100644 index 00000000..72126daf --- /dev/null +++ b/httpclient/sentryhttpclient.go @@ -0,0 +1,107 @@ +// 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" + + "github.com/getsentry/sentry-go" +) + +// SentryRoundTripTracerOption provides a specific type in which defines the option for SentryRoundTripper. +type SentryRoundTripTracerOption func(*SentryRoundTripper) + +// WithTags allows the RoundTripper to includes additional tags. +func WithTags(tags map[string]string) SentryRoundTripTracerOption { + return func(t *SentryRoundTripper) { + for k, v := range tags { + t.tags[k] = v + } + } +} + +// WithTag allows the RoundTripper to includes additional tag. +func WithTag(key, value string) SentryRoundTripTracerOption { + return func(t *SentryRoundTripper) { + t.tags[key] = value + } +} + +// 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 + } + + t := &SentryRoundTripper{ + originalRoundTripper: originalRoundTripper, + tags: make(map[string]string), + } + + 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 + + tags map[string]string +} + +func (s *SentryRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + // Only create the `http.client` span only if there is a parent span. + parentSpan := sentry.GetSpanFromContext(request.Context()) + if parentSpan == nil { + return s.originalRoundTripper.RoundTrip(request) + } + + cleanRequestURL := request.URL.Redacted() + + span := parentSpan.StartChild("http.client", sentry.WithTransactionName(fmt.Sprintf("%s %s", request.Method, cleanRequestURL))) + span.Tags = s.tags + 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 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..6bf61b7f --- /dev/null +++ b/httpclient/sentryhttpclient_test.go @@ -0,0 +1,264 @@ +package sentryhttpclient_test + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "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 +} + +func (n *noopRoundTripper) RoundTrip(request *http.Request) (*http.Response, 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 + 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", + Tags: map[string]string{}, + 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", + Tags: map[string]string{}, + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusOK, + }, + }, + { + RequestMethod: "HEAD", + RequestURL: "https://example.com:8443/foo?bar=123&abc=def", + TracerOptions: []sentryhttpclient.SentryRoundTripTracerOption{sentryhttpclient.WithTag("user", "def"), sentryhttpclient.WithTags(map[string]string{"domain": "example.com"})}, + 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"), + }, + Tags: map[string]string{ + "user": "def", + "domain": "example.com", + }, + 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", + Tags: map[string]string{}, + Origin: "manual", + Sampled: sentry.SampledTrue, + Status: sentry.SpanStatusOK, + }, + }, + } + + 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 { + t.Fatal(err) + } + + roundTripper := &noopRoundTripper{ + ExpectResponseStatus: tt.WantStatus, + ExpectResponseLength: tt.WantResponseLength, + } + + client := &http.Client{ + Transport: sentryhttpclient.NewSentryRoundTripper(roundTripper, tt.TracerOptions...), + } + + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + + 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 !foundMatch { + t.Errorf("Span mismatch (-want +got):\n%s", strings.Join(diffs, "\n")) + } + } +} + +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() + } + }) +} diff --git a/tracing.go b/tracing.go index 0f5ade2e..fb1e5acd 100644 --- a/tracing.go +++ b/tracing.go @@ -206,6 +206,17 @@ func StartSpan(ctx context.Context, operation string, options ...SpanOption) *Sp return &span } +// GetSpanFromContext retrieves attached *sentry.Span instance from context.Context. +// If there are no spans, it will return nil. +func GetSpanFromContext(ctx context.Context) *Span { + span, ok := ctx.Value(spanContextKey{}).(*Span) + if ok { + return span + } + + return nil +} + // Finish sets the span's end time, unless already set. If the span is the root // of a span tree, Finish sends the span tree to Sentry as a transaction. // diff --git a/tracing_test.go b/tracing_test.go index 795c33d3..53225080 100644 --- a/tracing_test.go +++ b/tracing_test.go @@ -1019,3 +1019,23 @@ func TestSpanFinishConcurrentlyWithoutRaces(_ *testing.T) { time.Sleep(50 * time.Millisecond) } + +func TestGetSpanFromContext(t *testing.T) { + t.Run("Exists", func(t *testing.T) { + span := StartSpan(context.Background(), "something") + + value := GetSpanFromContext(span.Context()) + if value == nil { + t.Error("expecting `value` to be not nil") + } else if span.Op != "something" { + t.Errorf("expecting `span.Op` to be 'something', instead got %q", span.Op) + } + }) + + t.Run("Nil", func(t *testing.T) { + value := GetSpanFromContext(context.Background()) + if value != nil { + t.Error("expecting `value` to be nil") + } + }) +}