diff --git a/example/otel/go.mod b/example/otel/go.mod new file mode 100644 index 00000000000..b86dd6e85a5 --- /dev/null +++ b/example/otel/go.mod @@ -0,0 +1,24 @@ +module github.com/google/go-github/v82/example/otel + +go 1.24.0 + +require ( + github.com/google/go-github/v82 v82.0.0 + github.com/google/go-github/v82/otel v0.0.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 + go.opentelemetry.io/otel/sdk v1.24.0 +) + +require ( + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-querystring v1.2.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/sys v0.17.0 // indirect +) + +replace github.com/google/go-github/v82 => ../../ + +replace github.com/google/go-github/v82/otel => ../../otel diff --git a/example/otel/go.sum b/example/otel/go.sum new file mode 100644 index 00000000000..508a96dc708 --- /dev/null +++ b/example/otel/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDOpt2M8OTG92cWqUESvzh2MxiR5xY8= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/example/otel/main.go b/example/otel/main.go new file mode 100644 index 00000000000..05dc4df4af4 --- /dev/null +++ b/example/otel/main.go @@ -0,0 +1,64 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This example demonstrates how to use the otel transport to instrument +// the go-github client with OpenTelemetry tracing. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + + "github.com/google/go-github/v82/github" + "github.com/google/go-github/v82/otel" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/sdk/trace" +) + +func main() { + // Initialize stdout exporter to see traces in console + exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) + if err != nil { + log.Fatalf("failed to initialize stdouttrace exporter: %v", err) + } + + tp := trace.NewTracerProvider( + trace.WithBatcher(exporter), + ) + defer func() { + if err := tp.Shutdown(context.Background()); err != nil { + log.Fatal(err) + } + }() + + // Configure HTTP client with OTel transport + httpClient := &http.Client{ + Transport: otel.NewTransport( + http.DefaultTransport, + otel.WithTracerProvider(tp), + ), + } + + // Create GitHub client + client := github.NewClient(httpClient) + + // Make a request (Get Rate Limits is public and cheap) + limits, resp, err := client.RateLimit.Get(context.Background()) + if err != nil { + log.Printf("Error fetching rate limits: %v", err) + } else { + fmt.Printf("Core Rate Limit: %v/%v (Resets at %v)\n", + limits.GetCore().Remaining, + limits.GetCore().Limit, + limits.GetCore().Reset) + } + + // Check if we captured attributes in response + if resp != nil { + fmt.Printf("Response Status: %v\n", resp.Status) + } +} diff --git a/github/github.go b/github/github.go index db642f7894b..490351d1343 100644 --- a/github/github.go +++ b/github/github.go @@ -40,8 +40,8 @@ const ( headerRateLimit = "X-Ratelimit-Limit" headerRateRemaining = "X-Ratelimit-Remaining" headerRateUsed = "X-Ratelimit-Used" - headerRateReset = "X-Ratelimit-Reset" - headerRateResource = "X-Ratelimit-Resource" + HeaderRateReset = "X-Ratelimit-Reset" + HeaderRateResource = "X-Ratelimit-Resource" headerOTP = "X-Github-Otp" headerRetryAfter = "Retry-After" @@ -794,12 +794,12 @@ func parseRate(r *http.Response) Rate { if used := r.Header.Get(headerRateUsed); used != "" { rate.Used, _ = strconv.Atoi(used) } - if reset := r.Header.Get(headerRateReset); reset != "" { + if reset := r.Header.Get(HeaderRateReset); reset != "" { if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 { rate.Reset = Timestamp{time.Unix(v, 0)} } } - if resource := r.Header.Get(headerRateResource); resource != "" { + if resource := r.Header.Get(HeaderRateResource); resource != "" { rate.Resource = resource } return rate @@ -820,7 +820,7 @@ func parseSecondaryRate(r *http.Response) *time.Duration { // According to GitHub support, endpoints might return x-ratelimit-reset instead, // as an integer which represents the number of seconds since epoch UTC, // representing the time to resume making requests. - if v := r.Header.Get(headerRateReset); v != "" { + if v := r.Header.Get(HeaderRateReset); v != "" { secondsSinceEpoch, _ := strconv.ParseInt(v, 10, 64) // Error handling is noop. retryAfter := time.Until(time.Unix(secondsSinceEpoch, 0)) return &retryAfter diff --git a/github/github_test.go b/github/github_test.go index b95abc4d7b9..8cffb7f7ef6 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -1233,8 +1233,8 @@ func TestDo_rateLimit(t *testing.T) { w.Header().Set(headerRateLimit, "60") w.Header().Set(headerRateRemaining, "59") w.Header().Set(headerRateUsed, "1") - w.Header().Set(headerRateReset, "1372700873") - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, "1372700873") + w.Header().Set(HeaderRateResource, "core") }) req, _ := client.NewRequest("GET", ".", nil) @@ -1352,8 +1352,8 @@ func TestDo_rateLimit_errorResponse(t *testing.T) { w.Header().Set(headerRateLimit, "60") w.Header().Set(headerRateRemaining, "59") w.Header().Set(headerRateUsed, "1") - w.Header().Set(headerRateReset, "1372700873") - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, "1372700873") + w.Header().Set(HeaderRateResource, "core") http.Error(w, "Bad Request", 400) }) @@ -1393,8 +1393,8 @@ func TestDo_rateLimit_rateLimitError(t *testing.T) { w.Header().Set(headerRateLimit, "60") w.Header().Set(headerRateRemaining, "0") w.Header().Set(headerRateUsed, "60") - w.Header().Set(headerRateReset, "1372700873") - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, "1372700873") + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1443,8 +1443,8 @@ func TestDo_rateLimit_noNetworkCall(t *testing.T) { w.Header().Set(headerRateLimit, "60") w.Header().Set(headerRateRemaining, "0") w.Header().Set(headerRateUsed, "60") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1511,8 +1511,8 @@ func TestDo_rateLimit_ignoredFromCache(t *testing.T) { w.Header().Set(headerRateLimit, "60") w.Header().Set(headerRateRemaining, "0") w.Header().Set(headerRateUsed, "60") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1559,8 +1559,8 @@ func TestDo_rateLimit_sleepUntilResponseResetLimit(t *testing.T) { w.Header().Set(headerRateLimit, "60") w.Header().Set(headerRateRemaining, "0") w.Header().Set(headerRateUsed, "60") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1572,8 +1572,8 @@ func TestDo_rateLimit_sleepUntilResponseResetLimit(t *testing.T) { w.Header().Set(headerRateLimit, "5000") w.Header().Set(headerRateRemaining, "5000") w.Header().Set(headerRateUsed, "0") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -1603,8 +1603,8 @@ func TestDo_rateLimit_sleepUntilResponseResetLimitRetryOnce(t *testing.T) { w.Header().Set(headerRateLimit, "60") w.Header().Set(headerRateRemaining, "0") w.Header().Set(headerRateUsed, "60") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1637,8 +1637,8 @@ func TestDo_rateLimit_sleepUntilClientResetLimit(t *testing.T) { w.Header().Set(headerRateLimit, "5000") w.Header().Set(headerRateRemaining, "5000") w.Header().Set(headerRateUsed, "0") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -1670,8 +1670,8 @@ func TestDo_rateLimit_abortSleepContextCancelled(t *testing.T) { w.Header().Set(headerRateLimit, "60") w.Header().Set(headerRateRemaining, "0") w.Header().Set(headerRateUsed, "60") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1705,8 +1705,8 @@ func TestDo_rateLimit_abortSleepContextCancelledClientLimit(t *testing.T) { w.Header().Set(headerRateLimit, "5000") w.Header().Set(headerRateRemaining, "5000") w.Header().Set(headerRateUsed, "0") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -1857,7 +1857,7 @@ func TestDo_rateLimit_abuseRateLimitError_xRateLimitReset(t *testing.T) { mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set(headerRateReset, strconv.Itoa(int(blockUntil))) + w.Header().Set(HeaderRateReset, strconv.Itoa(int(blockUntil))) w.Header().Set(headerRateRemaining, "1") // set remaining to a value > 0 to distinct from a primary rate limit w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1916,7 +1916,7 @@ func TestDo_rateLimit_abuseRateLimitError_maxDuration(t *testing.T) { mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set(headerRateReset, strconv.Itoa(int(blockUntil))) + w.Header().Set(HeaderRateReset, strconv.Itoa(int(blockUntil))) w.Header().Set(headerRateRemaining, "1") // set remaining to a value > 0 to distinct from a primary rate limit w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1959,8 +1959,8 @@ func TestDo_rateLimit_disableRateLimitCheck(t *testing.T) { w.Header().Set(headerRateLimit, "5000") w.Header().Set(headerRateRemaining, "5000") w.Header().Set(headerRateUsed, "0") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -1995,8 +1995,8 @@ func TestDo_rateLimit_bypassRateLimitCheck(t *testing.T) { w.Header().Set(headerRateLimit, "5000") w.Header().Set(headerRateRemaining, "5000") w.Header().Set(headerRateUsed, "0") - w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) - w.Header().Set(headerRateResource, "core") + w.Header().Set(HeaderRateReset, fmt.Sprint(reset.Add(time.Hour).Unix())) + w.Header().Set(HeaderRateResource, "core") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -2138,8 +2138,8 @@ func TestCheckResponse_RateLimit(t *testing.T) { res.Header.Set(headerRateLimit, "60") res.Header.Set(headerRateRemaining, "0") res.Header.Set(headerRateUsed, "1") - res.Header.Set(headerRateReset, "243424") - res.Header.Set(headerRateResource, "core") + res.Header.Set(HeaderRateReset, "243424") + res.Header.Set(HeaderRateResource, "core") var err *RateLimitError errors.As(CheckResponse(res), &err) @@ -2198,8 +2198,8 @@ func TestCheckResponse_RateLimit_TooManyRequests(t *testing.T) { res.Header.Set(headerRateLimit, "60") res.Header.Set(headerRateRemaining, "0") res.Header.Set(headerRateUsed, "60") - res.Header.Set(headerRateReset, "243424") - res.Header.Set(headerRateResource, "core") + res.Header.Set(HeaderRateReset, "243424") + res.Header.Set(HeaderRateResource, "core") var err *RateLimitError errors.As(CheckResponse(res), &err) diff --git a/otel/go.mod b/otel/go.mod new file mode 100644 index 00000000000..7f98b378b84 --- /dev/null +++ b/otel/go.mod @@ -0,0 +1,20 @@ +module github.com/google/go-github/v82/otel + +go 1.24.0 + +require ( + github.com/google/go-github/v82 v82.0.0 + go.opentelemetry.io/otel v1.27.0 + go.opentelemetry.io/otel/metric v1.27.0 + go.opentelemetry.io/otel/sdk v1.27.0 + go.opentelemetry.io/otel/trace v1.27.0 +) + +require ( + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-querystring v1.2.0 // indirect + golang.org/x/sys v0.39.0 // indirect +) + +replace github.com/google/go-github/v82 => ../ diff --git a/otel/go.sum b/otel/go.sum new file mode 100644 index 00000000000..8b4543fb3b3 --- /dev/null +++ b/otel/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= +go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= +go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= +go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= +go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= +go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= +go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= +go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/otel/transport.go b/otel/transport.go new file mode 100644 index 00000000000..45a9f69ae73 --- /dev/null +++ b/otel/transport.go @@ -0,0 +1,127 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package otel provides OpenTelemetry instrumentation for the go-github client. +package otel + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/google/go-github/v82/github" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +const ( + // instrumentationName is the name of this instrumentation package. + // NOTE: This must be updated when the major version of go-github changes. + instrumentationName = "github.com/google/go-github/otel" +) + +// Transport is an http.RoundTripper that instrument requests with OpenTelemetry. +type Transport struct { + Base http.RoundTripper + Tracer trace.Tracer + Meter metric.Meter +} + +// NewTransport creates a new OpenTelemetry transport. +func NewTransport(base http.RoundTripper, opts ...Option) *Transport { + if base == nil { + base = http.DefaultTransport + } + t := &Transport{Base: base} + for _, opt := range opts { + opt(t) + } + if t.Tracer == nil { + t.Tracer = otel.Tracer(instrumentationName) + } + if t.Meter == nil { + t.Meter = otel.Meter(instrumentationName) + } + return t +} + +// RoundTrip implements http.RoundTripper. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + spanName := fmt.Sprintf("github/%v", req.Method) + // Start Span + ctx, span := t.Tracer.Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + // Inject Attributes + span.SetAttributes( + attribute.String("http.method", req.Method), + attribute.String("http.url", req.URL.String()), + attribute.String("http.host", req.URL.Host), + ) + + // Inject Propagation Headers + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header)) + + // Execute Request + resp, err := t.Base.RoundTrip(req) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + + // Capture response attributes + span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode)) + // Capture GitHub Specifics + if limit := resp.Header.Get("X-Ratelimit-Limit"); limit != "" { + if v, err := strconv.Atoi(limit); err == nil { + span.SetAttributes(attribute.Int("github.rate_limit.limit", v)) + } + } + if remaining := resp.Header.Get("X-Ratelimit-Remaining"); remaining != "" { + if v, err := strconv.Atoi(remaining); err == nil { + span.SetAttributes(attribute.Int("github.rate_limit.remaining", v)) + } + } + if reset := resp.Header.Get(github.HeaderRateReset); reset != "" { + span.SetAttributes(attribute.String("github.rate_limit.reset", reset)) + } + if reqID := resp.Header.Get("X-Github-Request-Id"); reqID != "" { + span.SetAttributes(attribute.String("github.request_id", reqID)) + } + if resource := resp.Header.Get(github.HeaderRateResource); resource != "" { + span.SetAttributes(attribute.String("github.rate_limit.resource", resource)) + } + + if resp.StatusCode >= 400 { + span.SetStatus(codes.Error, fmt.Sprintf("HTTP %v", resp.StatusCode)) + } else { + span.SetStatus(codes.Ok, "OK") + } + + return resp, nil +} + +// Option applies configuration to Transport. +type Option func(*Transport) + +// WithTracerProvider configures the TracerProvider. +func WithTracerProvider(tp trace.TracerProvider) Option { + return func(t *Transport) { + t.Tracer = tp.Tracer(instrumentationName) + } +} + +// WithMeterProvider configures the MeterProvider. +func WithMeterProvider(mp metric.MeterProvider) Option { + return func(t *Transport) { + t.Meter = mp.Meter(instrumentationName) + } +} diff --git a/otel/transport_test.go b/otel/transport_test.go new file mode 100644 index 00000000000..c005691490a --- /dev/null +++ b/otel/transport_test.go @@ -0,0 +1,197 @@ +// Copyright 2026 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package otel + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-github/v82/github" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +type mockTransport struct { + Response *http.Response + Err error +} + +func (m *mockTransport) RoundTrip(_ *http.Request) (*http.Response, error) { + if m.Err != nil { + return nil, m.Err + } + // Return valid response with injected headers + if m.Response != nil { + return m.Response, nil + } + return &http.Response{StatusCode: 200, Header: make(http.Header)}, nil +} + +func TestNewTransport_Defaults(t *testing.T) { + t.Parallel() + transport := NewTransport(nil) + if transport.Base != http.DefaultTransport { + t.Error("NewTransport(nil) should result in http.DefaultTransport") + } + if transport.Tracer == nil { + t.Error("NewTransport(nil) should set default Tracer") + } + if transport.Meter == nil { + t.Error("NewTransport(nil) should set default Meter") + } +} + +func TestRoundTrip_Spans(t *testing.T) { + t.Parallel() + // Setup Trace Provider + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider(trace.WithSyncer(exporter)) + + // Setup Headers + headers := http.Header{} + headers.Set("X-Ratelimit-Limit", "5000") + headers.Set("X-Ratelimit-Remaining", "4999") + headers.Set(github.HeaderRateReset, "1372700873") + headers.Set("X-Github-Request-Id", "1234-5678") + headers.Set(github.HeaderRateResource, "core") + + mockResp := &http.Response{ + StatusCode: 200, + Header: headers, + } + + transport := NewTransport( + &mockTransport{Response: mockResp}, + WithTracerProvider(tp), + ) + + req := httptest.NewRequest("GET", "https://api.github.com/users/google", nil) + _, err := transport.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip failed: %v", err) + } + + spans := exporter.GetSpans() + if len(spans) != 1 { + t.Fatalf("Expected 1 span, got %d", len(spans)) + } + span := spans[0] + + // Verify Name + if span.Name != "github/GET" { + t.Errorf("Expected span name 'github/GET', got '%v'", span.Name) + } + + // Verify Attributes + attrs := make(map[attribute.Key]attribute.Value) + for _, a := range span.Attributes { + attrs[a.Key] = a.Value + } + + expectedStringAttrs := map[attribute.Key]string{ + "http.method": "GET", + "http.url": "https://api.github.com/users/google", + "http.host": "api.github.com", + "github.rate_limit.reset": "1372700873", + "github.request_id": "1234-5678", + "github.rate_limit.resource": "core", + } + + for k, v := range expectedStringAttrs { + if got, ok := attrs[k]; !ok || got.AsString() != v { + t.Errorf("Expected attr '%v' = '%v', got '%v'", k, v, got) + } + } + + expectedIntAttrs := map[attribute.Key]int64{ + "http.status_code": 200, + "github.rate_limit.limit": 5000, + "github.rate_limit.remaining": 4999, + } + + for k, v := range expectedIntAttrs { + if got, ok := attrs[k]; !ok || got.AsInt64() != v { + t.Errorf("Expected attr '%v' = '%v', got '%v'", k, v, got) + } + } +} + +func TestRoundTrip_Error(t *testing.T) { + t.Parallel() + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider(trace.WithSyncer(exporter)) + + mockErr := errors.New("network failure") + transport := NewTransport( + &mockTransport{Err: mockErr}, + WithTracerProvider(tp), + ) + + req := httptest.NewRequest("POST", "https://api.github.com/repos/new", nil) + _, err := transport.RoundTrip(req) + + if !errors.Is(err, mockErr) { + t.Errorf("Expected error '%v', got '%v'", mockErr, err) + } + + spans := exporter.GetSpans() + if len(spans) != 1 { + t.Fatalf("Expected 1 span, got %d", len(spans)) + } + span := spans[0] + + if span.Status.Code != codes.Error { + t.Errorf("Expected span status Error, got %v", span.Status.Code) + } + if span.Status.Description != "network failure" { + t.Errorf("Expected span description 'network failure', got '%v'", span.Status.Description) + } +} + +func TestRoundTrip_HTTPError(t *testing.T) { + t.Parallel() + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider(trace.WithSyncer(exporter)) + + mockResp := &http.Response{ + StatusCode: 404, + Header: make(http.Header), + } + transport := NewTransport( + &mockTransport{Response: mockResp}, + WithTracerProvider(tp), + ) + + req := httptest.NewRequest("DELETE", "https://api.github.com/user", nil) + _, err := transport.RoundTrip(req) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + spans := exporter.GetSpans() + span := spans[0] + + if span.Status.Code != codes.Error { + t.Errorf("Expected span status Error, got %v", span.Status.Code) + } + if span.Status.Description != "HTTP 404" { + t.Errorf("Expected span description 'HTTP 404', got '%v'", span.Status.Description) + } +} + +func TestWithMeterProvider(t *testing.T) { + t.Parallel() + meter := otel.GetMeterProvider() + transport := NewTransport(nil, WithMeterProvider(meter)) + if transport.Meter == nil { + t.Error("WithMeterProvider failed to set Meter") + } +}