diff --git a/README.md b/README.md index 4155bd6..3246b28 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Yet another HTTP Client in go with very simple yet essential features - [x] Different http configurations support - Timeout, Headers, QueryParams, FormParams, MultipartFormParams, CircuitBreaker - [x] Supports GET, POST, POSTMultiPartFormData, POSTFormData, PUT - [ ] DELETE, PATCH -- [ ] Add metrics either via open telemetry or prometheus metrics +- [x] Add metrics via OpenTelemetry - [ ] Add support for retries, it should have either default/custom or without any retrier ### Features of Tracing constructs @@ -85,6 +85,51 @@ post, err := rustic.GET[[]UserPost](context.Background(), fmt.Println(post) ``` +#### HTTP Client OpenTelemetry Metrics + +The HTTP client can be configured to emit OpenTelemetry metrics, allowing you to monitor its performance and behavior, such as request counts, durations, and errors. + +##### Enabling Metrics + +To enable metrics collection, use the `WithMetricsEnabled` option when creating a new `HTTPClient`: + +```go +import ( + "learn-go-dependency-injection/httpClient" // Or your actual import path + // ... other necessary imports for metrics exporter +) + +// Example: Create a client with metrics enabled +client := httpClient.NewHTTPClient( + httpClient.WithMetricsEnabled(true), + // You might also want to enable tracing or other options + // httpClient.WithTraceEnabled(true), +) + +// Now, when client.Do is called, metrics will be recorded. +``` + +##### Collected Metrics + +When enabled, the client records the following metrics: + +* **`http.client.request.count`** (Counter): The total number of HTTP requests made. + * Attributes: `http.method`, `http.url`, `http.status_code` (if a response is received). +* **`http.client.request.duration`** (Histogram): The duration of each HTTP request in seconds. + * Attributes: `http.method`, `http.url`, `http.status_code` (if a response is received). +* **`http.client.request.errors`** (Counter): The number of HTTP requests that resulted in an error during the request execution (e.g., network errors, dial errors). This does not count HTTP status codes like 4xx or 5xx as errors for this specific metric unless the `Do` method itself returns an error. + * Attributes: `http.method`, `http.url`. + +##### Setting up an Exporter + +To actually collect and visualize these metrics, you need to configure an OpenTelemetry metrics exporter and a `MeterProvider` in your application. This setup is standard for OpenTelemetry. + +For a practical example of how to set up a simple stdout exporter (which prints metrics to the console), please refer to the example program: +[`example/httpMetrics/main.go`](./example/httpMetrics/main.go) + +This example demonstrates initializing the exporter and making it available for the `HTTPClient` to use. You can replace the stdout exporter with other exporters like Prometheus, OTLP, etc., depending on your monitoring infrastructure. + + #### Opentelementry Tracing ##### Using with Echo Framework diff --git a/example/go.mod b/example/go.mod index 874414b..d650a7c 100644 --- a/example/go.mod +++ b/example/go.mod @@ -8,6 +8,7 @@ require ( github.com/labstack/echo/v4 v4.13.3 github.com/rag594/rustic v0.0.0-00010101000000-000000000000 github.com/sony/gobreaker/v2 v2.1.0 + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.34.0 ) require ( diff --git a/example/httpMetrics/main.go b/example/httpMetrics/main.go new file mode 100644 index 0000000..806772b --- /dev/null +++ b/example/httpMetrics/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "learn-go-dependency-injection/httpClient" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" +) + +func main() { + // Initialize stdoutmetric exporter + exporter, err := stdoutmetric.New(stdoutmetric.WithPrettyPrint()) + if err != nil { + log.Fatalf("failed to create stdoutmetric exporter: %v", err) + } + + // Create a new MeterProvider with the exporter and set it as global + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter, sdkmetric.PeriodicReaderOption{ + Interval: 1 * time.Second, // Export metrics every second + }))) + otel.SetMeterProvider(mp) + + // Ensure provider is shutdown at the end + defer func() { + if err := mp.Shutdown(context.Background()); err != nil { + log.Printf("Error shutting down meter provider: %v", err) + } + }() + + // Create an HTTPClient with metrics enabled + client := httpClient.NewHTTPClient( + httpClient.WithMetricsEnabled(true), + httpClient.WithTraceEnabled(false), // Explicitly disable tracing if not needed for this example + ) + + log.Println("Making HTTP GET request to https://www.google.com...") + // Create a new HTTP request + req, err := http.NewRequestWithContext(context.Background(), "GET", "https://www.google.com", nil) + if err != nil { + log.Fatalf("Failed to create request: %v", err) + } + + // Make the HTTP request using the custom client + resp, err := client.Do(req) + if err != nil { + log.Fatalf("HTTP request failed: %v", err) + // Note: The error metric should be recorded by the client.Do method + } else { + defer resp.Body.Close() + log.Printf("Response Status: %s", resp.Status) + // Request count and duration metrics should be recorded by client.Do + } + + // Make another request to see more metrics + req2, err := http.NewRequestWithContext(context.Background(), "GET", "https://www.example.com", nil) + if err != nil { + log.Fatalf("Failed to create request for example.com: %v", err) + } + resp2, err := client.Do(req2) + if err != nil { + log.Fatalf("HTTP request to example.com failed: %v", err) + } else { + defer resp2.Body.Close() + log.Printf("Response Status (example.com): %s", resp2.Status) + } + + // Make a request that should fail (non-existent domain) + log.Println("Making HTTP GET request to https://nonexistentdomain.invalid...") + reqFail, err := http.NewRequestWithContext(context.Background(), "GET", "https://nonexistentdomain.invalid", nil) + if err != nil { + log.Fatalf("Failed to create request for nonexistentdomain.invalid: %v", err) + } + _, err = client.Do(reqFail) + if err != nil { + log.Printf("HTTP request to nonexistentdomain.invalid failed as expected: %v", err) + } else { + log.Println("Request to nonexistentdomain.invalid succeeded unexpectedly") + } + + + // Keep the program running for a bit to allow metrics to be exported. + // The stdout exporter typically exports periodically. + log.Println("Waiting for metrics to be exported (approx 5 seconds)...") + time.Sleep(5 * time.Second) // Adjust as needed for exporter to flush + + log.Println("Example finished.") +} diff --git a/httpClient/http_client.go b/httpClient/http_client.go index e617010..776e837 100644 --- a/httpClient/http_client.go +++ b/httpClient/http_client.go @@ -2,15 +2,59 @@ package httpClient import ( "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel" // Added this import + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" "net/http" "runtime" + "time" ) +// MetricType defines the type of an OpenTelemetry metric. +type MetricType string + +const ( + // Int64Counter is a metric type for a counter that records int64 values. + Int64Counter MetricType = "Int64Counter" + // Float64Histogram is a metric type for a histogram that records float64 values. + Float64Histogram MetricType = "Float64Histogram" + // Add other types as needed, e.g., Int64UpDownCounter, Float64Gauge, etc. +) + +// MetricConfig defines the configuration for an OpenTelemetry metric. +type MetricConfig struct { + Name string + Description string + Type MetricType + // Future additions could include Unit, specific advice for histogram boundaries, etc. +} + +var defaultMetricConfigs = []MetricConfig{ + { + Name: "http.client.request.count", + Description: "The total number of HTTP requests made by the client.", + Type: Int64Counter, + }, + { + Name: "http.client.request.duration", + Description: "The duration of HTTP requests made by the client, in seconds.", + Type: Float64Histogram, + }, + { + Name: "http.client.request.errors", + Description: "The number of HTTP requests by the client that resulted in an error (e.g., network errors).", + Type: Int64Counter, + }, +} + // HTTPClient wrapper over net/http client with tracing type HTTPClient struct { - Client *http.Client - TraceEnabled bool - ServiceName string + Client *http.Client + TraceEnabled bool + MetricsEnabled bool // Added MetricsEnabled field + ServiceName string + meter metric.Meter + instruments map[string]any // Stores initialized metric instruments } // HTTPClientOption different options to configure the HTTPClient @@ -23,6 +67,13 @@ func WithTraceEnabled(e bool) HTTPClientOption { } } +// WithMetricsEnabled allows to toggle metrics +func WithMetricsEnabled(e bool) HTTPClientOption { + return func(client *HTTPClient) { + client.MetricsEnabled = e + } +} + // NewHTTPClient creates a new HTTPClient with DefaultTransport // TODO: add options to configure transport func NewHTTPClient(opt ...HTTPClientOption) *HTTPClient { @@ -37,15 +88,91 @@ func NewHTTPClient(opt ...HTTPClientOption) *HTTPClient { httpClient.Client.Transport = http.DefaultTransport.(*http.Transport) } + if httpClient.MetricsEnabled { + // Initialize meter + httpClient.meter = otel.Meter("httpClient") + httpClient.instruments = make(map[string]any) + + // Initialize instruments based on defaultMetricConfigs + for _, config := range defaultMetricConfigs { + switch config.Type { + case Int64Counter: + instrument := metric.Must(httpClient.meter).Int64Counter( + config.Name, + metric.WithDescription(config.Description), + ) + httpClient.instruments[config.Name] = instrument + case Float64Histogram: + instrument := metric.Must(httpClient.meter).Float64Histogram( + config.Name, + metric.WithDescription(config.Description), + ) + httpClient.instruments[config.Name] = instrument + // Add cases for other metric types if they are defined in MetricType + default: + // Optionally log or handle unknown metric types + // For now, we'll ignore unknown types to avoid panicking if defaultMetricConfigs is extended with unsupported types. + } + } + } + return &httpClient } // Do makes an HTTP request with the native `http.Do` interface func (c *HTTPClient) Do(request *http.Request) (*http.Response, error) { + startTime := time.Now() + resp, err := c.Client.Do(request) - if err != nil { + + duration := time.Since(startTime).Seconds() + + attributes := []attribute.KeyValue{ + attribute.String("http.method", request.Method), + attribute.String("http.url", request.URL.String()), + } + + if resp != nil { + attributes = append(attributes, attribute.Int("http.status_code", resp.StatusCode)) + } + + // Record metrics if enabled and instruments are available + if c.MetricsEnabled && c.instruments != nil && c.meter != nil { + // Record duration + if inst, ok := c.instruments["http.client.request.duration"]; ok { + if histogram, typeOk := inst.(metric.Float64Histogram); typeOk { + histogram.Record(request.Context(), duration, metric.WithAttributes(attributes...)) + } + } + + // Record count + if inst, ok := c.instruments["http.client.request.count"]; ok { + if counter, typeOk := inst.(metric.Int64Counter); typeOk { + counter.Add(request.Context(), 1, metric.WithAttributes(attributes...)) + } + } + + if err != nil { + // Record error count + if inst, ok := c.instruments["http.client.request.errors"]; ok { + if counter, typeOk := inst.(metric.Int64Counter); typeOk { + // For errors, attributes should not include http.status_code if resp is nil + errorAttributes := []attribute.KeyValue{ + attribute.String("http.method", request.Method), + attribute.String("http.url", request.URL.String()), + } + // If resp is not nil (e.g. a 500 error from server still means Do itself didn't error yet), + // we might want to include status code. However, this block is specifically for `err != nil`, + // which implies a client-side error or an error before getting a full response. + counter.Add(request.Context(), 1, metric.WithAttributes(errorAttributes...)) + } + } + return nil, err // Return error after attempting to record it + } + } else if err != nil { // If metrics are not enabled (or instruments map is nil), but there's an error, still return the error return nil, err } + return resp, err } diff --git a/httpClient/http_client_test.go b/httpClient/http_client_test.go new file mode 100644 index 0000000..7d4f4f1 --- /dev/null +++ b/httpClient/http_client_test.go @@ -0,0 +1,433 @@ +package httpClient + +import ( + "context" + "fmt" + "io" + "learn-go-dependency-injection/httpClient" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/metric/metrictest" // Hope this path is correct for SDK v1.34.0 +) + +// Helper function to setup an in-memory meter provider for tests +func setupTestMetrics(t *testing.T) (*metrictest.MeterProvider, *httpClient.HTTPClient) { + mp := metrictest.NewMeterProvider() + otel.SetMeterProvider(mp) // Set as global for the test + + // It's important that the httpClient uses the meter from this provider. + // Since the client gets the global provider, this setup should work. + client := httpClient.NewHTTPClient( + httpClient.WithMetricsEnabled(true), + ) + return mp, client +} + +// Helper function to setup an in-memory meter provider for tests where client is configured later +func setupTestMeterProvider(t *testing.T) *metrictest.MeterProvider { + mp := metrictest.NewMeterProvider() + otel.SetMeterProvider(mp) // Set as global for the test + return mp +} + +func TestHTTPClient_Metrics_Enabled_SuccessfulRequest(t *testing.T) { + mp, client := setupTestMetrics(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "Hello, client") + })) + defer server.Close() + + req, _ := http.NewRequest("GET", server.URL, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + // Force a collection + rm, err := mp.Collect(context.Background()) + if err != nil { + t.Fatalf("Failed to collect metrics: %v", err) + } + + if len(rm.ScopeMetrics) == 0 { + t.Fatalf("Expected scope metrics, got none") + } + // Assuming one scope "httpClient" + if len(rm.ScopeMetrics[0].Metrics) < 2 { + t.Fatalf("Expected at least 2 metrics (count, duration), got %d", len(rm.ScopeMetrics[0].Metrics)) + } + + var foundCount, foundDuration bool + for _, m := range rm.ScopeMetrics[0].Metrics { + switch m.Name { + case "http.client.request.count": + foundCount = true + data, ok := m.Data.(metricdata.Sum[int64]) + if !ok { + t.Fatalf("metric http.client.request.count is not a Sum[int64]: %T", m.Data) + } + if len(data.DataPoints) != 1 { + t.Fatalf("Expected 1 data point for count, got %d", len(data.DataPoints)) + } + dp := data.DataPoints[0] + if dp.Value != 1 { + t.Errorf("Expected count to be 1, got %d", dp.Value) + } + // Check attributes + expectedAttrs := attribute.NewSet( + attribute.String("http.method", "GET"), + attribute.String("http.url", server.URL), + attribute.Int("http.status_code", http.StatusOK), + ) + if !expectedAttrs.Equals(&dp.Attributes) { + t.Errorf("Expected attributes %v, got %v", expectedAttrs, dp.Attributes) + } + case "http.client.request.duration": + foundDuration = true + data, ok := m.Data.(metricdata.Histogram[float64]) + if !ok { + t.Fatalf("metric http.client.request.duration is not a Histogram[float64]: %T", m.Data) + } + if len(data.DataPoints) != 1 { + t.Fatalf("Expected 1 data point for duration, got %d", len(data.DataPoints)) + } + dp := data.DataPoints[0] + if dp.Count != 1 { // Check if a recording was made + t.Errorf("Expected duration to have 1 event, got %d", dp.Count) + } + // Check attributes + expectedAttrs := attribute.NewSet( + attribute.String("http.method", "GET"), + attribute.String("http.url", server.URL), + attribute.Int("http.status_code", http.StatusOK), + ) + if !expectedAttrs.Equals(&dp.Attributes) { + t.Errorf("Expected attributes %v, got %v", expectedAttrs, dp.Attributes) + } + } + } + if !foundCount { + t.Error("Metric http.client.request.count not found") + } + if !foundDuration { + t.Error("Metric http.client.request.duration not found") + } +} + +func TestHTTPClient_Metrics_Enabled_ErrorRequest(t *testing.T) { + mp, client := setupTestMetrics(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + req, _ := http.NewRequest("POST", server.URL+"/path", nil) // Different method and path + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Expected no error from Do itself (server error), got %v", err) + } + defer resp.Body.Close() + + + // Second request - this one will be a client-side error (network error) + reqFail, _ := http.NewRequest("GET", "http://localhost:0", nil) // Non-routable, should cause conn refused + _, err = client.Do(reqFail) + if err == nil { + t.Fatalf("Expected error for request to non-existent server, got nil") + } + + + rm, err := mp.Collect(context.Background()) + if err != nil { + t.Fatalf("Failed to collect metrics: %v", err) + } + + if len(rm.ScopeMetrics) == 0 { + t.Fatalf("Expected scope metrics, got none") + } + + var foundErrorCount, foundRequestCount, foundDurationCount int + var totalRequests int64 = 0 + var totalErrors int64 = 0 + + for _, m := range rm.ScopeMetrics[0].Metrics { + switch m.Name { + case "http.client.request.count": + data, _ := m.Data.(metricdata.Sum[int64]) + for _, dp := range data.DataPoints { + totalRequests += dp.Value + foundRequestCount++ + } + case "http.client.request.duration": + data, _ := m.Data.(metricdata.Histogram[float64]) + for _, dp := range data.DataPoints { + foundDurationCount += int(dp.Count) + } + case "http.client.request.errors": + data, ok := m.Data.(metricdata.Sum[int64]) + if !ok { + t.Fatalf("metric http.client.request.errors is not a Sum[int64]: %T", m.Data) + } + if len(data.DataPoints) == 0 { + // It's possible no errors were recorded if the filter for attributes is too specific + // Or if the error happened before attribute creation + t.Logf("No data points found for http.client.request.errors. Data: %+v", data) + } + for _, dp := range data.DataPoints { + totalErrors += dp.Value + foundErrorCount++ + // Check attributes for the client-side error + // The server error (500) is not an "error" for this counter, it's a successful request that got a 500 + // This counter is for client-side errors or if Do returns an error. + expectedAttrs := attribute.NewSet( + attribute.String("http.method", "GET"), + attribute.String("http.url", "http://localhost:0"), + // No status code for client-side errors + ) + // Note: The order of requests matters here. This check is brittle if other errors are also recorded. + // We expect one error data point for the client-side error. + if !expectedAttrs.Equals(&dp.Attributes) { + t.Errorf("Expected error attributes %v, got %v", expectedAttrs, dp.Attributes) + } + } + } + } + + if totalRequests != 2 { + t.Errorf("Expected total request count to be 2, got %d (found %d DPs)", totalRequests, foundRequestCount) + } + if foundDurationCount != 2 { // One for 500, one for client error (duration still recorded) + t.Errorf("Expected total duration events to be 2, got %d", foundDurationCount) + } + if totalErrors != 1 { // Only the client-side error + t.Errorf("Expected total error count to be 1, got %d (found %d DPs)", totalErrors, foundErrorCount) + } + if foundErrorCount == 0 && totalErrors == 0 { // Be more explicit if no error DPs found + t.Error("No data points found for http.client.request.errors metric, but expected 1") + } +} + + +func TestHTTPClient_Metrics_Disabled(t *testing.T) { + mp := setupTestMeterProvider(t) // just need the meter provider to check it's empty + + client := httpClient.NewHTTPClient( + httpClient.WithMetricsEnabled(false), // Metrics disabled + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + req, _ := http.NewRequest("GET", server.URL, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + // Try a failing request too + reqFail, _ := http.NewRequest("GET", "http://localhost:0", nil) + _, err = client.Do(reqFail) + if err == nil { + t.Fatalf("Expected error for request to non-existent server, got nil") + } + + rm, err := mp.Collect(context.Background()) + if err != nil { + t.Fatalf("Failed to collect metrics: %v", err) + } + + if len(rm.ScopeMetrics) != 0 { + // Log the unexpected metrics for debugging + for _, sm := range rm.ScopeMetrics { + t.Logf("Unexpected scope metric: %s", sm.Scope.Name) + for _, m := range sm.Metrics { + t.Logf(" Unexpected metric: %s", m.Name) + } + } + t.Errorf("Expected no scope metrics when MetricsEnabled is false, got %d", len(rm.ScopeMetrics)) + } +} + +// Test for correct attributes, especially for status codes and errors +func TestHTTPClient_Metrics_Attributes(t *testing.T) { + mp, client := setupTestMetrics(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/ok") { + w.WriteHeader(http.StatusOK) + } else if strings.Contains(r.URL.Path, "/notfound") { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + // Request 1: OK + reqOK, _ := http.NewRequest("GET", server.URL+"/ok", nil) + respOK, _ := client.Do(reqOK) + defer respOK.Body.Close() + + // Request 2: NotFound + reqNotFound, _ := http.NewRequest("PUT", server.URL+"/notfound", nil) + respNotFound, _ := client.Do(reqNotFound) + defer respNotFound.Body.Close() + + // Request 3: Client-side error + reqClientError, _ := http.NewRequest("POST", "http://badhost:12345", nil) + _, errClient := client.Do(reqClientError) + if errClient == nil { + t.Fatal("Expected client error, got none") + } + + rm, err := mp.Collect(context.Background()) + if err != nil { + t.Fatalf("Failed to collect metrics: %v", err) + } + + if len(rm.ScopeMetrics) == 0 || len(rm.ScopeMetrics[0].Metrics) == 0 { + t.Fatal("No metrics collected") + } + + metrics := rm.ScopeMetrics[0].Metrics + expectedDataPoints := 3 // total requests + + var countDps, durationDps, errorDps int + for _, m := range metrics { + switch m.Name { + case "http.client.request.count": + sum, _ := m.Data.(metricdata.Sum[int64]) + countDps = len(sum.DataPoints) + for _, dp := range sum.DataPoints { + url, _ := dp.Attributes.Value("http.url") + method, _ := dp.Attributes.Value("http.method") + statusCode, statusOk := dp.Attributes.Value("http.status_code") + + if url.AsString() == server.URL+"/ok" { + if method.AsString() != "GET" || !statusOk || statusCode.AsInt64() != http.StatusOK { + t.Errorf("Incorrect attributes for /ok count: %+v", dp.Attributes) + } + } else if url.AsString() == server.URL+"/notfound" { + if method.AsString() != "PUT" || !statusOk || statusCode.AsInt64() != http.StatusNotFound { + t.Errorf("Incorrect attributes for /notfound count: %+v", dp.Attributes) + } + } else if url.AsString() == "http://badhost:12345" { + // For client error, status code attribute should not be present on count if Do returns error before response. + // However, our current implementation adds it if resp is nil, which might be an issue. + // Let's assume current behavior: status code is not present for client error if resp is nil. + // Actually, client.Do adds status code if resp is not nil. + // For the client error (badhost), resp will be nil. So status_code should NOT be present. + if method.AsString() != "POST" || statusOk { // statusOk should be false + t.Errorf("Incorrect attributes for client error count: %+v. StatusOK: %v", dp.Attributes, statusOk) + } + } + } + case "http.client.request.duration": + hist, _ := m.Data.(metricdata.Histogram[float64]) + durationDps = len(hist.DataPoints) + // Similar checks for duration attributes + for _, dp := range hist.DataPoints { + url, _ := dp.Attributes.Value("http.url") + method, _ := dp.Attributes.Value("http.method") + statusCode, statusOk := dp.Attributes.Value("http.status_code") + + if url.AsString() == server.URL+"/ok" { + if method.AsString() != "GET" || !statusOk || statusCode.AsInt64() != http.StatusOK { + t.Errorf("Incorrect attributes for /ok duration: %+v", dp.Attributes) + } + } else if url.AsString() == server.URL+"/notfound" { + if method.AsString() != "PUT" || !statusOk || statusCode.AsInt64() != http.StatusNotFound { + t.Errorf("Incorrect attributes for /notfound duration: %+v", dp.Attributes) + } + } else if url.AsString() == "http://badhost:12345" { + if method.AsString() != "POST" || statusOk { + t.Errorf("Incorrect attributes for client error duration: %+v. StatusOK: %v", dp.Attributes, statusOk) + } + } + } + case "http.client.request.errors": + sum, _ := m.Data.(metricdata.Sum[int64]) + errorDps = len(sum.DataPoints) + if errorDps != 1 { + t.Errorf("Expected 1 error data point, got %d", errorDps) + } + for _, dp := range sum.DataPoints { + url, _ := dp.Attributes.Value("http.url") + method, _ := dp.Attributes.Value("http.method") + _, statusOk := dp.Attributes.Value("http.status_code") // Should not be present + + if !(url.AsString() == "http://badhost:12345" && method.AsString() == "POST" && !statusOk) { + t.Errorf("Incorrect attributes for errors: %+v", dp.Attributes) + } + } + } + } + + if countDps != expectedDataPoints { + t.Errorf("Expected %d data points for request_count, got %d", expectedDataPoints, countDps) + } + if durationDps != expectedDataPoints { + t.Errorf("Expected %d data points for request_duration, got %d", expectedDataPoints, durationDps) + } + // errorDps already checked +} + +// Ensure httpClient uses the globally set MeterProvider if not overridden. +// This test relies on the global meter provider being set. +func TestHTTPClient_UsesGlobalMeterProvider(t *testing.T) { + mp := metrictest.NewMeterProvider() + otel.SetMeterProvider(mp) // Set as global + + // Create client AFTER setting global provider + client := httpClient.NewHTTPClient(httpClient.WithMetricsEnabled(true)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + req, _ := http.NewRequest("GET", server.URL, nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + defer resp.Body.Close() + + rm, err := mp.Collect(context.Background()) + if err != nil { + t.Fatalf("Failed to collect metrics: %v", err) + } + + if len(rm.ScopeMetrics) == 0 { + t.Fatal("No metrics collected, client might not be using the global meter provider") + } + if len(rm.ScopeMetrics[0].Metrics) == 0 { + t.Fatal("No metrics from the httpClient scope, client might not be using the global meter provider") + } + // Basic check for count metric + found := false + for _, m := range rm.ScopeMetrics[0].Metrics { + if m.Name == "http.client.request.count" { + found = true + break + } + } + if !found { + t.Error("http.client.request.count not found, client may not be using the set meter provider") + } +}