Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: http client integration #876

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions httpclient/sentryhttpclient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I wrote this, the docs said trace propagation targets is only for avoiding CORS-related issue, and since the dotnet SDK does not have that option too, I don't implement it here.

But is my assumption wrong here? Should all SDK implements trace propagation targets then?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

ctx := request.Context()
cleanRequestURL := request.URL.Redacted()

span := sentry.StartSpan(ctx, "http.client", sentry.WithTransactionName(fmt.Sprintf("%s %s", request.Method, cleanRequestURL)))

for k, v := range s.tags {
span.SetTag(k, v)
}

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())
Comment on lines +118 to +120
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To support the recently added "Tracing without Performance" feature, we should use hub.GetTraceparent() and hub.GetBaggage()


response, err := s.originalRoundTripper.RoundTrip(request)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also want to handle the err case


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),
}
244 changes: 244 additions & 0 deletions httpclient/sentryhttpclient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
package sentryhttpclient_test

import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"io"
"net/http"
"strconv"
"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
WantTransaction *sentry.Event
}{
{
RequestMethod: "GET",
RequestURL: "https://example.com/foo",
WantStatus: 200,
WantResponseLength: 0,
WantTransaction: &sentry.Event{
Extra: 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(""),
},
Level: sentry.LevelInfo,
Transaction: "GET https://example.com/foo",
Type: "transaction",
TransactionInfo: &sentry.TransactionInfo{Source: "custom"},
},
},
{
RequestMethod: "GET",
RequestURL: "https://example.com:443/foo/bar?baz=123#readme",
TracerOptions: []sentryhttpclient.SentryRoundTripTracerOption{nil, nil, nil},
WantStatus: 200,
WantResponseLength: 0,
WantTransaction: &sentry.Event{
Extra: 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"),
},
Level: sentry.LevelInfo,
Transaction: "GET https://example.com:443/foo/bar?baz=123#readme",
Type: "transaction",
TransactionInfo: &sentry.TransactionInfo{Source: "custom"},
},
},
{
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,
WantTransaction: &sentry.Event{
Extra: 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",
},
Level: sentry.LevelInfo,
Transaction: "HEAD https://example.com:8443/foo?bar=123&abc=def",
Type: "transaction",
TransactionInfo: &sentry.TransactionInfo{Source: "custom"},
},
},
{
RequestMethod: "POST",
RequestURL: "https://john:[email protected]:4321/secret",
WantStatus: 200,
WantResponseLength: 1024,
WantTransaction: &sentry.Event{
Extra: 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"),
},
Level: sentry.LevelInfo,
Transaction: "POST https://john:[email protected]:4321/secret",
Type: "transaction",
TransactionInfo: &sentry.TransactionInfo{Source: "custom"},
},
},
}

transactionsCh := make(chan *sentry.Event, len(tests))

sentryClient, err := sentry.NewClient(sentry.ClientOptions{
EnableTracing: true,
TracesSampleRate: 1.0,
BeforeSendTransaction: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
transactionsCh <- event
return event
},
})
if err != nil {
t.Fatal(err)
}

var want []*sentry.Event
for _, tt := range tests {
hub := sentry.NewHub(sentryClient, sentry.NewScope())
ctx := sentry.SetHubOnContext(context.Background(), hub)

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()
want = append(want, tt.WantTransaction)
}

if ok := sentryClient.Flush(testutils.FlushTimeout()); !ok {
t.Fatal("sentry.Flush timed out")
}
close(transactionsCh)
var got []*sentry.Event
for e := range transactionsCh {
got = append(got, e)
}

optstrans := cmp.Options{
cmpopts.IgnoreFields(
sentry.Event{},
"Contexts", "EventID", "Platform", "Modules",
"Release", "Sdk", "ServerName", "Timestamp",
"sdkMetaData", "StartTime", "Spans",
),
cmpopts.IgnoreFields(
sentry.Request{},
"Env",
),
}
if diff := cmp.Diff(want, got, optstrans); diff != "" {
t.Fatalf("Transaction mismatch (-want +got):\n%s", diff)
}
}

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()
}
})
}
Loading