diff --git a/example/otel/go.mod b/example/otel/go.mod new file mode 100644 index 00000000000..59178f9b900 --- /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.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 + go.opentelemetry.io/otel v1.27.0 // indirect + go.opentelemetry.io/otel/metric v1.27.0 // indirect + go.opentelemetry.io/otel/trace v1.27.0 // indirect + golang.org/x/sys v0.39.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..6d1563c3d2c --- /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.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/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.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/example/otel/main.go b/example/otel/main.go new file mode 100644 index 00000000000..1f082399e51 --- /dev/null +++ b/example/otel/main.go @@ -0,0 +1,63 @@ +// 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), + ), + } + + 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..5b75f0f04c6 100644 --- a/github/github.go +++ b/github/github.go @@ -36,14 +36,16 @@ const ( defaultUserAgent = "go-github" + "/" + Version uploadBaseURL = "https://uploads.github.com/" - headerAPIVersion = "X-Github-Api-Version" - headerRateLimit = "X-Ratelimit-Limit" - headerRateRemaining = "X-Ratelimit-Remaining" - headerRateUsed = "X-Ratelimit-Used" - headerRateReset = "X-Ratelimit-Reset" - headerRateResource = "X-Ratelimit-Resource" - headerOTP = "X-Github-Otp" - headerRetryAfter = "Retry-After" + HeaderRateLimit = "X-Ratelimit-Limit" + HeaderRateRemaining = "X-Ratelimit-Remaining" + HeaderRateUsed = "X-Ratelimit-Used" + HeaderRateReset = "X-Ratelimit-Reset" + HeaderRateResource = "X-Ratelimit-Resource" + HeaderRequestID = "X-Github-Request-Id" + + headerAPIVersion = "X-Github-Api-Version" + headerOTP = "X-Github-Otp" + headerRetryAfter = "Retry-After" headerTokenExpiration = "Github-Authentication-Token-Expiration" @@ -785,21 +787,21 @@ func (r *Response) populatePageValues() { // parseRate parses the rate related headers. func parseRate(r *http.Response) Rate { var rate Rate - if limit := r.Header.Get(headerRateLimit); limit != "" { + if limit := r.Header.Get(HeaderRateLimit); limit != "" { rate.Limit, _ = strconv.Atoi(limit) } - if remaining := r.Header.Get(headerRateRemaining); remaining != "" { + if remaining := r.Header.Get(HeaderRateRemaining); remaining != "" { rate.Remaining, _ = strconv.Atoi(remaining) } - if used := r.Header.Get(headerRateUsed); used != "" { + 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 +822,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 @@ -1454,7 +1456,7 @@ func CheckResponse(r *http.Response) error { // Primary rate limit exceeded: GitHub returns 403 or 429 with X-RateLimit-Remaining: 0 // See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api case (r.StatusCode == http.StatusForbidden || r.StatusCode == http.StatusTooManyRequests) && - r.Header.Get(headerRateRemaining) == "0": + r.Header.Get(HeaderRateRemaining) == "0": return &RateLimitError{ Rate: parseRate(r), Response: errorResponse.Response, diff --git a/github/github_test.go b/github/github_test.go index b95abc4d7b9..328da1e72f3 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -1230,11 +1230,11 @@ func TestDo_rateLimit(t *testing.T) { client, mux, _ := setup(t) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { - 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(HeaderRateLimit, "60") + w.Header().Set(HeaderRateRemaining, "59") + w.Header().Set(HeaderRateUsed, "1") + w.Header().Set(HeaderRateReset, "1372700873") + w.Header().Set(HeaderRateResource, "core") }) req, _ := client.NewRequest("GET", ".", nil) @@ -1349,11 +1349,11 @@ func TestDo_rateLimit_errorResponse(t *testing.T) { client, mux, _ := setup(t) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { - 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(HeaderRateLimit, "60") + w.Header().Set(HeaderRateRemaining, "59") + w.Header().Set(HeaderRateUsed, "1") + w.Header().Set(HeaderRateReset, "1372700873") + w.Header().Set(HeaderRateResource, "core") http.Error(w, "Bad Request", 400) }) @@ -1390,11 +1390,11 @@ func TestDo_rateLimit_rateLimitError(t *testing.T) { client, mux, _ := setup(t) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1440,11 +1440,11 @@ func TestDo_rateLimit_noNetworkCall(t *testing.T) { reset := time.Now().UTC().Add(time.Minute).Round(time.Second) // Rate reset is a minute from now, with 1 second precision. mux.HandleFunc("/first", func(w http.ResponseWriter, _ *http.Request) { - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1508,11 +1508,11 @@ func TestDo_rateLimit_ignoredFromCache(t *testing.T) { // By adding the X-From-Cache header we pretend this is served from a cache. mux.HandleFunc("/first", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-From-Cache", "1") - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1556,11 +1556,11 @@ func TestDo_rateLimit_sleepUntilResponseResetLimit(t *testing.T) { mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { if firstRequest { firstRequest = false - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1569,11 +1569,11 @@ func TestDo_rateLimit_sleepUntilResponseResetLimit(t *testing.T) { }`) return } - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -1600,11 +1600,11 @@ func TestDo_rateLimit_sleepUntilResponseResetLimitRetryOnce(t *testing.T) { requestCount := 0 mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { requestCount++ - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1634,11 +1634,11 @@ func TestDo_rateLimit_sleepUntilClientResetLimit(t *testing.T) { requestCount := 0 mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { requestCount++ - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -1667,11 +1667,11 @@ func TestDo_rateLimit_abortSleepContextCancelled(t *testing.T) { requestCount := 0 mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { requestCount++ - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusForbidden) fmt.Fprintln(w, `{ @@ -1702,11 +1702,11 @@ func TestDo_rateLimit_abortSleepContextCancelledClientLimit(t *testing.T) { requestCount := 0 mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { requestCount++ - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -1857,8 +1857,8 @@ 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(headerRateRemaining, "1") // set remaining to a value > 0 to distinct from a primary rate limit + 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, `{ "message": "You have triggered an abuse detection mechanism ...", @@ -1916,8 +1916,8 @@ 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(headerRateRemaining, "1") // set remaining to a value > 0 to distinct from a primary rate limit + 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, `{ "message": "You have triggered an abuse detection mechanism ...", @@ -1956,11 +1956,11 @@ func TestDo_rateLimit_disableRateLimitCheck(t *testing.T) { requestCount := 0 mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { requestCount++ - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -1992,11 +1992,11 @@ func TestDo_rateLimit_bypassRateLimitCheck(t *testing.T) { requestCount := 0 mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { requestCount++ - 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(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("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{}`) @@ -2135,11 +2135,11 @@ func TestCheckResponse_RateLimit(t *testing.T) { Body: io.NopCloser(strings.NewReader(`{"message":"m", "documentation_url": "url"}`)), } - 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(HeaderRateLimit, "60") + res.Header.Set(HeaderRateRemaining, "0") + res.Header.Set(HeaderRateUsed, "1") + res.Header.Set(HeaderRateReset, "243424") + res.Header.Set(HeaderRateResource, "core") var err *RateLimitError errors.As(CheckResponse(res), &err) @@ -2195,11 +2195,11 @@ func TestCheckResponse_RateLimit_TooManyRequests(t *testing.T) { Body: io.NopCloser(strings.NewReader(`{"message":"m", "documentation_url": "url"}`)), } - 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(HeaderRateLimit, "60") + res.Header.Set(HeaderRateRemaining, "0") + res.Header.Set(HeaderRateUsed, "60") + 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..1f8770ee701 --- /dev/null +++ b/otel/transport.go @@ -0,0 +1,126 @@ +// 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. + 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(github.HeaderRateLimit); limit != "" { + if v, err := strconv.Atoi(limit); err == nil { + span.SetAttributes(attribute.Int("github.rate_limit.limit", v)) + } + } + if remaining := resp.Header.Get(github.HeaderRateRemaining); 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(github.HeaderRequestID); 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") + } +}