Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions example/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
94 changes: 94 additions & 0 deletions example/httpMetrics/main.go
Original file line number Diff line number Diff line change
@@ -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.")
}
135 changes: 131 additions & 4 deletions httpClient/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down
Loading