Skip to content

Commit 1f65923

Browse files
committed
Add GitHub API request metrics
1 parent 287e4c6 commit 1f65923

File tree

3 files changed

+258
-7
lines changed

3 files changed

+258
-7
lines changed

pkg/gh/github.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,21 @@ func NewClient(token string) (*github.Client, error) {
6060
Cache: httpCache,
6161
MarkCachedResponses: true,
6262
}
63+
clientTransport := newInstrumentedTransport(transport)
6364

6465
if !config.Config.IsGHES() {
65-
return github.NewClient(&http.Client{Transport: transport}), nil
66+
return github.NewClient(&http.Client{Transport: clientTransport}), nil
6667
}
6768

68-
return github.NewClient(&http.Client{Transport: transport}).WithEnterpriseURLs(config.Config.GitHubURL, config.Config.GitHubURL)
69+
return github.NewClient(&http.Client{Transport: clientTransport}).WithEnterpriseURLs(config.Config.GitHubURL, config.Config.GitHubURL)
6970
}
7071

7172
// NewClientGitHubApps create a client of GitHub using Private Key from GitHub Apps
7273
// header is "Authorization: Bearer YOUR_JWT"
7374
// docs: https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app
7475
func NewClientGitHubApps() (*github.Client, error) {
7576
if !config.Config.IsGHES() {
76-
return github.NewClient(&http.Client{Transport: &appTransport}), nil
77+
return github.NewClient(&http.Client{Transport: newInstrumentedTransport(&appTransport)}), nil
7778
}
7879

7980
apiEndpoint, err := getAPIEndpoint()
@@ -83,7 +84,7 @@ func NewClientGitHubApps() (*github.Client, error) {
8384

8485
itr := appTransport
8586
itr.BaseURL = apiEndpoint.String()
86-
return github.NewClient(&http.Client{Transport: &appTransport}).WithEnterpriseURLs(config.Config.GitHubURL, config.Config.GitHubURL)
87+
return github.NewClient(&http.Client{Transport: newInstrumentedTransport(&appTransport)}).WithEnterpriseURLs(config.Config.GitHubURL, config.Config.GitHubURL)
8788
}
8889

8990
// NewClientInstallation create a client of GitHub using installation ID from GitHub Apps
@@ -93,14 +94,14 @@ func NewClientInstallation(installationID int64) (*github.Client, error) {
9394
itr := getInstallationTransport(installationID)
9495

9596
if !config.Config.IsGHES() {
96-
return github.NewClient(&http.Client{Transport: itr}), nil
97+
return github.NewClient(&http.Client{Transport: newInstrumentedTransport(itr)}), nil
9798
}
9899
apiEndpoint, err := getAPIEndpoint()
99100
if err != nil {
100101
return nil, fmt.Errorf("failed to get GitHub API Endpoint: %w", err)
101102
}
102103
itr.BaseURL = apiEndpoint.String()
103-
return github.NewClient(&http.Client{Transport: itr}).WithEnterpriseURLs(config.Config.GitHubURL, config.Config.GitHubURL)
104+
return github.NewClient(&http.Client{Transport: newInstrumentedTransport(itr)}).WithEnterpriseURLs(config.Config.GitHubURL, config.Config.GitHubURL)
104105
}
105106

106107
func setInstallationTransport(installationID int64, itr ghinstallation.Transport) {
@@ -159,7 +160,7 @@ func ExistGitHubRepository(scope string, accessToken string) error {
159160
return fmt.Errorf("failed to get repository url: %w", err)
160161
}
161162

162-
client := http.DefaultClient
163+
client := &http.Client{Transport: newInstrumentedTransport(http.DefaultTransport)}
163164
req, err := http.NewRequest(http.MethodGet, repoURL, nil)
164165
if err != nil {
165166
return fmt.Errorf("failed to create request: %w", err)

pkg/gh/metrics.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package gh
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"time"
10+
11+
"github.com/m4ns0ur/httpcache"
12+
"github.com/prometheus/client_golang/prometheus"
13+
"github.com/prometheus/client_golang/prometheus/promauto"
14+
)
15+
16+
const githubAPINamespace = "myshoes"
17+
18+
var (
19+
githubAPIRequestsTotal = promauto.NewCounterVec(
20+
prometheus.CounterOpts{
21+
Namespace: githubAPINamespace,
22+
Subsystem: "github_api",
23+
Name: "requests_total",
24+
Help: "Total number of GitHub API requests.",
25+
},
26+
[]string{"path", "method", "status_class"},
27+
)
28+
githubAPIRequestDuration = promauto.NewHistogramVec(
29+
prometheus.HistogramOpts{
30+
Namespace: githubAPINamespace,
31+
Subsystem: "github_api",
32+
Name: "request_duration_seconds",
33+
Help: "Duration of GitHub API requests in seconds.",
34+
Buckets: prometheus.DefBuckets,
35+
},
36+
[]string{"path", "method", "status_class"},
37+
)
38+
githubAPIErrorsTotal = promauto.NewCounterVec(
39+
prometheus.CounterOpts{
40+
Namespace: githubAPINamespace,
41+
Subsystem: "github_api",
42+
Name: "errors_total",
43+
Help: "Total number of GitHub API request errors.",
44+
},
45+
[]string{"path", "method", "error_type"},
46+
)
47+
githubAPIInflight = promauto.NewGaugeVec(
48+
prometheus.GaugeOpts{
49+
Namespace: githubAPINamespace,
50+
Subsystem: "github_api",
51+
Name: "inflight",
52+
Help: "Number of in-flight GitHub API requests.",
53+
},
54+
[]string{"path", "method"},
55+
)
56+
githubAPICacheTotal = promauto.NewCounterVec(
57+
prometheus.CounterOpts{
58+
Namespace: githubAPINamespace,
59+
Subsystem: "github_api",
60+
Name: "cache_total",
61+
Help: "Total number of GitHub API cache hits/misses.",
62+
},
63+
[]string{"path", "method", "result"},
64+
)
65+
)
66+
67+
type instrumentedTransport struct {
68+
next http.RoundTripper
69+
}
70+
71+
func newInstrumentedTransport(next http.RoundTripper) http.RoundTripper {
72+
if next == nil {
73+
next = http.DefaultTransport
74+
}
75+
if _, ok := next.(*instrumentedTransport); ok {
76+
return next
77+
}
78+
return &instrumentedTransport{next: next}
79+
}
80+
81+
func (t *instrumentedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
82+
start := time.Now()
83+
84+
path := "unknown"
85+
method := "UNKNOWN"
86+
if req != nil {
87+
method = req.Method
88+
if req.URL != nil && req.URL.Path != "" {
89+
path = req.URL.Path
90+
}
91+
}
92+
93+
githubAPIInflight.WithLabelValues(path, method).Inc()
94+
defer githubAPIInflight.WithLabelValues(path, method).Dec()
95+
96+
resp, err := t.next.RoundTrip(req)
97+
98+
statusClass := "error"
99+
if err == nil && resp != nil {
100+
statusClass = fmt.Sprintf("%dxx", resp.StatusCode/100)
101+
}
102+
githubAPIRequestsTotal.WithLabelValues(path, method, statusClass).Inc()
103+
githubAPIRequestDuration.WithLabelValues(path, method, statusClass).Observe(time.Since(start).Seconds())
104+
105+
if err != nil {
106+
githubAPIErrorsTotal.WithLabelValues(path, method, classifyGitHubAPIError(err)).Inc()
107+
return resp, err
108+
}
109+
110+
if resp != nil {
111+
cacheResult := "miss"
112+
if resp.Header.Get(httpcache.XFromCache) == "1" {
113+
cacheResult = "hit"
114+
}
115+
githubAPICacheTotal.WithLabelValues(path, method, cacheResult).Inc()
116+
}
117+
118+
return resp, err
119+
}
120+
121+
func classifyGitHubAPIError(err error) string {
122+
if errors.Is(err, context.Canceled) {
123+
return "canceled"
124+
}
125+
if errors.Is(err, context.DeadlineExceeded) {
126+
return "deadline_exceeded"
127+
}
128+
var netErr net.Error
129+
if errors.As(err, &netErr) && netErr.Timeout() {
130+
return "timeout"
131+
}
132+
return "transport"
133+
}

pkg/gh/metrics_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package gh
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/m4ns0ur/httpcache"
10+
"github.com/prometheus/client_golang/prometheus/testutil"
11+
)
12+
13+
type stubTransport struct {
14+
resp *http.Response
15+
err error
16+
}
17+
18+
func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
19+
if s.resp != nil && s.resp.Request == nil {
20+
s.resp.Request = req
21+
}
22+
return s.resp, s.err
23+
}
24+
25+
type timeoutErr struct{}
26+
27+
func (timeoutErr) Error() string { return "timeout" }
28+
func (timeoutErr) Timeout() bool { return true }
29+
func (timeoutErr) Temporary() bool { return true }
30+
31+
func TestInstrumentedTransportMetrics(t *testing.T) {
32+
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/org/repo", nil)
33+
if err != nil {
34+
t.Fatalf("failed to create request: %v", err)
35+
}
36+
path := req.URL.Path
37+
method := req.Method
38+
39+
resp := &http.Response{
40+
StatusCode: http.StatusOK,
41+
Header: make(http.Header),
42+
Body: io.NopCloser(bytes.NewBufferString("ok")),
43+
}
44+
45+
transport := newInstrumentedTransport(&stubTransport{resp: resp})
46+
47+
baseReq := testutil.ToFloat64(githubAPIRequestsTotal.WithLabelValues(path, method, "2xx"))
48+
baseCache := testutil.ToFloat64(githubAPICacheTotal.WithLabelValues(path, method, "miss"))
49+
baseInflight := testutil.ToFloat64(githubAPIInflight.WithLabelValues(path, method))
50+
51+
if _, err := transport.RoundTrip(req); err != nil {
52+
t.Fatalf("RoundTrip error: %v", err)
53+
}
54+
55+
if got := testutil.ToFloat64(githubAPIRequestsTotal.WithLabelValues(path, method, "2xx")); got != baseReq+1 {
56+
t.Fatalf("requests_total mismatch: got=%v want=%v", got, baseReq+1)
57+
}
58+
if got := testutil.ToFloat64(githubAPICacheTotal.WithLabelValues(path, method, "miss")); got != baseCache+1 {
59+
t.Fatalf("cache_total miss mismatch: got=%v want=%v", got, baseCache+1)
60+
}
61+
if got := testutil.ToFloat64(githubAPIInflight.WithLabelValues(path, method)); got != baseInflight {
62+
t.Fatalf("inflight mismatch: got=%v want=%v", got, baseInflight)
63+
}
64+
}
65+
66+
func TestInstrumentedTransportCacheHit(t *testing.T) {
67+
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/org/repo", nil)
68+
if err != nil {
69+
t.Fatalf("failed to create request: %v", err)
70+
}
71+
path := req.URL.Path
72+
method := req.Method
73+
74+
resp := &http.Response{
75+
StatusCode: http.StatusOK,
76+
Header: make(http.Header),
77+
Body: io.NopCloser(bytes.NewBufferString("cached")),
78+
}
79+
resp.Header.Set(httpcache.XFromCache, "1")
80+
81+
transport := newInstrumentedTransport(&stubTransport{resp: resp})
82+
83+
baseCache := testutil.ToFloat64(githubAPICacheTotal.WithLabelValues(path, method, "hit"))
84+
85+
if _, err := transport.RoundTrip(req); err != nil {
86+
t.Fatalf("RoundTrip error: %v", err)
87+
}
88+
89+
if got := testutil.ToFloat64(githubAPICacheTotal.WithLabelValues(path, method, "hit")); got != baseCache+1 {
90+
t.Fatalf("cache_total hit mismatch: got=%v want=%v", got, baseCache+1)
91+
}
92+
}
93+
94+
func TestInstrumentedTransportErrorMetrics(t *testing.T) {
95+
req, err := http.NewRequest(http.MethodGet, "https://api.github.com/repos/org/repo", nil)
96+
if err != nil {
97+
t.Fatalf("failed to create request: %v", err)
98+
}
99+
path := req.URL.Path
100+
method := req.Method
101+
102+
transport := newInstrumentedTransport(&stubTransport{err: timeoutErr{}})
103+
104+
baseReq := testutil.ToFloat64(githubAPIRequestsTotal.WithLabelValues(path, method, "error"))
105+
baseErr := testutil.ToFloat64(githubAPIErrorsTotal.WithLabelValues(path, method, "timeout"))
106+
107+
if _, err := transport.RoundTrip(req); err == nil {
108+
t.Fatal("expected error, got nil")
109+
}
110+
111+
if got := testutil.ToFloat64(githubAPIRequestsTotal.WithLabelValues(path, method, "error")); got != baseReq+1 {
112+
t.Fatalf("requests_total error mismatch: got=%v want=%v", got, baseReq+1)
113+
}
114+
if got := testutil.ToFloat64(githubAPIErrorsTotal.WithLabelValues(path, method, "timeout")); got != baseErr+1 {
115+
t.Fatalf("errors_total mismatch: got=%v want=%v", got, baseErr+1)
116+
}
117+
}

0 commit comments

Comments
 (0)