Skip to content

Commit 9adec3c

Browse files
authored
Add metrics to SaneHTTPClient (#4471)
* Add metrics to SaneHTTPClient 1. Increment counter for URL 2. Time the latency 3. Increment counter for non-200 calls Added Tests * resolve lint issues
1 parent cc50239 commit 9adec3c

File tree

3 files changed

+319
-2
lines changed

3 files changed

+319
-2
lines changed

pkg/common/http.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,45 @@ func NewCustomTransport(T http.RoundTripper) *CustomTransport {
108108
return &CustomTransport{T}
109109
}
110110

111+
type InstrumentedTransport struct {
112+
T http.RoundTripper
113+
}
114+
115+
func (t *InstrumentedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
116+
117+
sanitizedURL := sanitizeURL(req.URL.String())
118+
119+
// increment counter for the URL
120+
recordHTTPRequest(sanitizedURL)
121+
122+
// Record start time for latency measurement
123+
start := time.Now()
124+
125+
resp, err := t.T.RoundTrip(req)
126+
127+
// Time the latency
128+
duration := time.Since(start)
129+
130+
if err != nil {
131+
recordNetworkError(sanitizedURL)
132+
return nil, err
133+
}
134+
135+
if resp != nil {
136+
// record latency and increment counter for non-200 status code
137+
recordHTTPResponse(sanitizedURL, resp.StatusCode, duration.Seconds())
138+
}
139+
140+
return resp, err
141+
}
142+
143+
func NewInstrumentedTransport(T http.RoundTripper) *InstrumentedTransport {
144+
if T == nil {
145+
T = http.DefaultTransport
146+
}
147+
return &InstrumentedTransport{T}
148+
}
149+
111150
func ConstantResponseHttpClient(statusCode int, body string) *http.Client {
112151
return &http.Client{
113152
Timeout: DefaultResponseTimeout,
@@ -223,14 +262,14 @@ var saneTransport = &http.Transport{
223262
func SaneHttpClient() *http.Client {
224263
httpClient := &http.Client{}
225264
httpClient.Timeout = DefaultResponseTimeout
226-
httpClient.Transport = NewCustomTransport(saneTransport)
265+
httpClient.Transport = NewInstrumentedTransport(NewCustomTransport(saneTransport))
227266
return httpClient
228267
}
229268

230269
// SaneHttpClientTimeOut adds a custom timeout for some scanners
231270
func SaneHttpClientTimeOut(timeout time.Duration) *http.Client {
232271
httpClient := &http.Client{}
233272
httpClient.Timeout = timeout
234-
httpClient.Transport = NewCustomTransport(nil)
273+
httpClient.Transport = NewInstrumentedTransport(NewCustomTransport(nil))
235274
return httpClient
236275
}

pkg/common/http_metrics.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package common
2+
3+
import (
4+
"net/url"
5+
"strconv"
6+
7+
"github.com/prometheus/client_golang/prometheus"
8+
"github.com/prometheus/client_golang/prometheus/promauto"
9+
)
10+
11+
var (
12+
httpRequestsTotal = promauto.NewCounterVec(
13+
prometheus.CounterOpts{
14+
Namespace: MetricsNamespace,
15+
Subsystem: "http_client",
16+
Name: "requests_total",
17+
Help: "Total number of HTTP requests made, labeled by URL.",
18+
},
19+
[]string{"url"},
20+
)
21+
22+
httpRequestDuration = promauto.NewHistogramVec(
23+
prometheus.HistogramOpts{
24+
Namespace: MetricsNamespace,
25+
Subsystem: "http_client",
26+
Name: "request_duration_seconds",
27+
Help: "HTTP request latency in seconds, labeled by URL.",
28+
Buckets: prometheus.DefBuckets,
29+
},
30+
[]string{"url"},
31+
)
32+
33+
httpNon200ResponsesTotal = promauto.NewCounterVec(
34+
prometheus.CounterOpts{
35+
Namespace: MetricsNamespace,
36+
Subsystem: "http_client",
37+
Name: "non_200_responses_total",
38+
Help: "Total number of non-200 HTTP responses, labeled by URL and status code.",
39+
},
40+
[]string{"url", "status_code"},
41+
)
42+
)
43+
44+
// sanitizeURL sanitizes a URL to avoid high cardinality metrics.
45+
// It keeps only the host and path, removing query parameters, fragments, and user info.
46+
func sanitizeURL(rawURL string) string {
47+
if rawURL == "" {
48+
return "unknown"
49+
}
50+
51+
parsedURL, err := url.Parse(rawURL)
52+
if err != nil {
53+
return "invalid_url"
54+
}
55+
56+
// Build sanitized URL with just scheme, host, and path
57+
sanitized := &url.URL{
58+
Scheme: parsedURL.Scheme,
59+
Host: parsedURL.Host,
60+
Path: parsedURL.Path,
61+
}
62+
63+
// If host is empty, try to extract from the raw URL
64+
if sanitized.Host == "" {
65+
// For relative URLs or malformed URLs, just use a placeholder
66+
return "relative_or_invalid"
67+
}
68+
69+
// Normalize path
70+
if sanitized.Path == "" {
71+
sanitized.Path = "/"
72+
}
73+
74+
// Limit path length to avoid extremely long paths creating high cardinality
75+
if len(sanitized.Path) > 100 {
76+
sanitized.Path = sanitized.Path[:100] + "..."
77+
}
78+
79+
result := sanitized.String()
80+
81+
// Final fallback to avoid empty strings
82+
if result == "" {
83+
return "unknown"
84+
}
85+
86+
return result
87+
}
88+
89+
// recordHTTPRequest records metrics for an HTTP request.
90+
func recordHTTPRequest(sanitizedURL string) {
91+
httpRequestsTotal.WithLabelValues(sanitizedURL).Inc()
92+
}
93+
94+
// recordHTTPResponse records metrics for an HTTP response.
95+
func recordHTTPResponse(sanitizedURL string, statusCode int, durationSeconds float64) {
96+
// Record latency
97+
httpRequestDuration.WithLabelValues(sanitizedURL).Observe(durationSeconds)
98+
99+
// Record non-200 responses
100+
if statusCode != 200 {
101+
httpNon200ResponsesTotal.WithLabelValues(sanitizedURL, strconv.Itoa(statusCode)).Inc()
102+
}
103+
}
104+
105+
// recordNetworkError records metrics for failed HTTP response
106+
func recordNetworkError(sanitizedURL string) {
107+
httpNon200ResponsesTotal.WithLabelValues(sanitizedURL, "network_error").Inc()
108+
}

pkg/common/http_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import (
66
"net/http"
77
"net/http/httptest"
88
"slices"
9+
"strings"
910
"testing"
1011
"time"
1112

1213
"github.com/hashicorp/go-retryablehttp"
14+
"github.com/prometheus/client_golang/prometheus/testutil"
1315
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
1417
)
1518

1619
func TestRetryableHTTPClientCheckRetry(t *testing.T) {
@@ -269,3 +272,170 @@ func TestRetryableHTTPClientTimeout(t *testing.T) {
269272
})
270273
}
271274
}
275+
276+
func TestSanitizeURL(t *testing.T) {
277+
testCases := []struct {
278+
name string
279+
input string
280+
expected string
281+
}{
282+
{
283+
name: "valid https URL",
284+
input: "https://api.example.com/v1/users",
285+
expected: "https://api.example.com/v1/users",
286+
},
287+
{
288+
name: "URL with query parameters",
289+
input: "https://api.example.com/search?q=secret&limit=10",
290+
expected: "https://api.example.com/search",
291+
},
292+
{
293+
name: "URL with fragment",
294+
input: "https://example.com/page#section",
295+
expected: "https://example.com/page",
296+
},
297+
{
298+
name: "URL with user info",
299+
input: "https://user:[email protected]/path",
300+
expected: "https://api.example.com/path",
301+
},
302+
{
303+
name: "empty URL",
304+
input: "",
305+
expected: "unknown",
306+
},
307+
{
308+
name: "invalid URL",
309+
input: "not-a-url",
310+
expected: "relative_or_invalid",
311+
},
312+
{
313+
name: "very long path",
314+
input: "https://example.com/" + strings.Repeat("a", 150),
315+
expected: "https://example.com/" + strings.Repeat("a", 99) + "...", // 99 + 1 ("/") = 100 chars
316+
},
317+
{
318+
name: "root path",
319+
input: "https://example.com",
320+
expected: "https://example.com/",
321+
},
322+
}
323+
324+
for _, tc := range testCases {
325+
t.Run(tc.name, func(t *testing.T) {
326+
result := sanitizeURL(tc.input)
327+
assert.Equal(t, tc.expected, result)
328+
})
329+
}
330+
}
331+
332+
func TestSaneHttpClientMetrics(t *testing.T) {
333+
// Create a test server that returns different status codes
334+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
335+
switch r.URL.Path {
336+
case "/success":
337+
w.WriteHeader(http.StatusOK)
338+
_, _ = w.Write([]byte("success"))
339+
case "/error":
340+
w.WriteHeader(http.StatusInternalServerError)
341+
_, _ = w.Write([]byte("error"))
342+
case "/notfound":
343+
w.WriteHeader(http.StatusNotFound)
344+
_, _ = w.Write([]byte("not found"))
345+
default:
346+
w.WriteHeader(http.StatusOK)
347+
_, _ = w.Write([]byte("default"))
348+
}
349+
}))
350+
defer server.Close()
351+
352+
// Create a SaneHttpClient
353+
client := SaneHttpClient()
354+
355+
testCases := []struct {
356+
name string
357+
path string
358+
expectedStatusCode int
359+
expectsNon200 bool
360+
}{
361+
{
362+
name: "successful request",
363+
path: "/success",
364+
expectedStatusCode: 200,
365+
expectsNon200: false,
366+
},
367+
{
368+
name: "server error request",
369+
path: "/error",
370+
expectedStatusCode: 500,
371+
expectsNon200: true,
372+
},
373+
{
374+
name: "not found request",
375+
path: "/notfound",
376+
expectedStatusCode: 404,
377+
expectsNon200: true,
378+
},
379+
}
380+
381+
for _, tc := range testCases {
382+
t.Run(tc.name, func(t *testing.T) {
383+
var requestURL string
384+
if strings.HasPrefix(tc.path, "http") {
385+
requestURL = tc.path
386+
} else {
387+
requestURL = server.URL + tc.path
388+
}
389+
390+
// Get initial metric values
391+
sanitizedURL := sanitizeURL(requestURL)
392+
initialRequestsTotal := testutil.ToFloat64(httpRequestsTotal.WithLabelValues(sanitizedURL))
393+
394+
// Make the request
395+
resp, err := client.Get(requestURL)
396+
397+
require.NoError(t, err)
398+
defer resp.Body.Close()
399+
assert.Equal(t, tc.expectedStatusCode, resp.StatusCode)
400+
401+
// Check that request counter was incremented
402+
requestsTotal := testutil.ToFloat64(httpRequestsTotal.WithLabelValues(sanitizedURL))
403+
assert.Equal(t, initialRequestsTotal+1, requestsTotal)
404+
})
405+
}
406+
}
407+
408+
func TestInstrumentedTransport(t *testing.T) {
409+
// Create a mock transport that we can control
410+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
411+
w.WriteHeader(http.StatusOK)
412+
_, _ = w.Write([]byte("test response"))
413+
}))
414+
defer server.Close()
415+
416+
// Create instrumented transport
417+
transport := NewInstrumentedTransport(nil)
418+
client := &http.Client{
419+
Transport: transport,
420+
Timeout: 5 * time.Second,
421+
}
422+
423+
// Get initial metric value
424+
sanitizedURL := sanitizeURL(server.URL)
425+
initialCount := testutil.ToFloat64(httpRequestsTotal.WithLabelValues(sanitizedURL))
426+
427+
// Make a request
428+
resp, err := client.Get(server.URL)
429+
require.NoError(t, err)
430+
defer resp.Body.Close()
431+
432+
// Verify the request was successful
433+
assert.Equal(t, http.StatusOK, resp.StatusCode)
434+
435+
// Verify metrics were recorded
436+
finalCount := testutil.ToFloat64(httpRequestsTotal.WithLabelValues(sanitizedURL))
437+
assert.Equal(t, initialCount+1, finalCount)
438+
439+
// Note: Testing histogram metrics is complex due to the way Prometheus handles them
440+
// The main thing is that the request completed successfully and counters were incremented
441+
}

0 commit comments

Comments
 (0)