add the feature for observability
This commit is contained in:
24
pkg/metrics/http.go
Normal file
24
pkg/metrics/http.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
// HTTPMiddleware wraps an HTTP handler with OpenTelemetry instrumentation.
|
||||
func HTTPMiddleware(handler http.Handler, operation string) http.Handler {
|
||||
if !IsEnabled() {
|
||||
return handler
|
||||
}
|
||||
|
||||
return otelhttp.NewHandler(
|
||||
handler,
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
// HTTPHandler wraps an HTTP handler function with OpenTelemetry instrumentation.
|
||||
func HTTPHandler(handler http.HandlerFunc, operation string) http.Handler {
|
||||
return HTTPMiddleware(handler, operation)
|
||||
}
|
||||
186
pkg/metrics/metrics.go
Normal file
186
pkg/metrics/metrics.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
|
||||
otelmetric "go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
)
|
||||
|
||||
var (
|
||||
mp *metric.MeterProvider
|
||||
meter otelmetric.Meter
|
||||
prometheusRegistry *prometheus.Registry
|
||||
once sync.Once
|
||||
shutdownFunc func(context.Context) error
|
||||
ErrInvalidExporter = errors.New("invalid metrics exporter type")
|
||||
ErrMetricsNotInit = errors.New("metrics not initialized")
|
||||
)
|
||||
|
||||
// ExporterType represents the type of metrics exporter.
|
||||
type ExporterType string
|
||||
|
||||
const (
|
||||
// ExporterPrometheus exports metrics in Prometheus format.
|
||||
ExporterPrometheus ExporterType = "prometheus"
|
||||
)
|
||||
|
||||
// Config represents the configuration for metrics.
|
||||
type Config struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
ExporterType ExporterType `yaml:"exporterType"`
|
||||
ServiceName string `yaml:"serviceName"`
|
||||
ServiceVersion string `yaml:"serviceVersion"`
|
||||
Prometheus PrometheusConfig `yaml:"prometheus"`
|
||||
}
|
||||
|
||||
// PrometheusConfig represents Prometheus exporter configuration.
|
||||
type PrometheusConfig struct {
|
||||
Port string `yaml:"port"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
// validate validates the metrics configuration.
|
||||
func (c *Config) validate() error {
|
||||
if !c.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.ExporterType != ExporterPrometheus {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidExporter, c.ExporterType)
|
||||
}
|
||||
|
||||
if c.ServiceName == "" {
|
||||
c.ServiceName = "beckn-onix"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitMetrics initializes the OpenTelemetry metrics SDK.
|
||||
func InitMetrics(cfg Config) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
var initErr error
|
||||
once.Do(func() {
|
||||
if initErr = cfg.validate(); initErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create resource with service information.
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("service.name", cfg.ServiceName),
|
||||
}
|
||||
if cfg.ServiceVersion != "" {
|
||||
attrs = append(attrs, attribute.String("service.version", cfg.ServiceVersion))
|
||||
}
|
||||
res, err := resource.New(
|
||||
context.Background(),
|
||||
resource.WithAttributes(attrs...),
|
||||
)
|
||||
if err != nil {
|
||||
initErr = fmt.Errorf("failed to create resource: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Always create Prometheus exporter for /metrics endpoint
|
||||
// Create a custom registry for the exporter so we can use it for HTTP serving
|
||||
promRegistry := prometheus.NewRegistry()
|
||||
promExporter, err := otelprom.New(otelprom.WithRegisterer(promRegistry))
|
||||
if err != nil {
|
||||
initErr = fmt.Errorf("failed to create Prometheus exporter: %w", err)
|
||||
return
|
||||
}
|
||||
prometheusRegistry = promRegistry
|
||||
|
||||
// Create readers based on configuration.
|
||||
var readers []metric.Reader
|
||||
|
||||
// Always add Prometheus reader for /metrics endpoint
|
||||
readers = append(readers, promExporter)
|
||||
|
||||
// Create meter provider with all readers
|
||||
opts := []metric.Option{
|
||||
metric.WithResource(res),
|
||||
}
|
||||
for _, reader := range readers {
|
||||
opts = append(opts, metric.WithReader(reader))
|
||||
}
|
||||
mp = metric.NewMeterProvider(opts...)
|
||||
|
||||
// Set global meter provider.
|
||||
otel.SetMeterProvider(mp)
|
||||
|
||||
// Create meter for this package.
|
||||
meter = mp.Meter("github.com/beckn-one/beckn-onix")
|
||||
|
||||
// Store shutdown function.
|
||||
shutdownFunc = func(ctx context.Context) error {
|
||||
return mp.Shutdown(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetMeter returns the global meter instance.
|
||||
func GetMeter() otelmetric.Meter {
|
||||
if meter == nil {
|
||||
// Return a no-op meter if not initialized.
|
||||
return otel.Meter("noop")
|
||||
}
|
||||
return meter
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the metrics provider.
|
||||
func Shutdown(ctx context.Context) error {
|
||||
if shutdownFunc == nil {
|
||||
return nil
|
||||
}
|
||||
return shutdownFunc(ctx)
|
||||
}
|
||||
|
||||
// IsEnabled returns whether metrics are enabled.
|
||||
func IsEnabled() bool {
|
||||
return mp != nil
|
||||
}
|
||||
|
||||
// MetricsHandler returns the HTTP handler for the /metrics endpoint.
|
||||
// Returns nil if metrics are not enabled.
|
||||
func MetricsHandler() http.Handler {
|
||||
if prometheusRegistry == nil {
|
||||
return nil
|
||||
}
|
||||
// Use promhttp to serve the Prometheus registry
|
||||
return promhttp.HandlerFor(prometheusRegistry, promhttp.HandlerOpts{})
|
||||
}
|
||||
|
||||
// InitAllMetrics initializes all metrics subsystems.
|
||||
// This includes request metrics and runtime metrics.
|
||||
// Returns an error if any initialization fails.
|
||||
func InitAllMetrics() error {
|
||||
if !IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := InitRequestMetrics(); err != nil {
|
||||
return fmt.Errorf("failed to initialize request metrics: %w", err)
|
||||
}
|
||||
if err := InitRuntimeMetrics(); err != nil {
|
||||
return fmt.Errorf("failed to initialize runtime metrics: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
200
pkg/metrics/requests.go
Normal file
200
pkg/metrics/requests.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
otelmetric "go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
var (
|
||||
// Inbound request metrics
|
||||
inboundRequestsTotal otelmetric.Int64Counter
|
||||
inboundSignValidationTotal otelmetric.Int64Counter
|
||||
inboundSchemaValidationTotal otelmetric.Int64Counter
|
||||
|
||||
// Outbound request metrics
|
||||
outboundRequestsTotal otelmetric.Int64Counter
|
||||
outboundRequests2XX otelmetric.Int64Counter
|
||||
outboundRequests4XX otelmetric.Int64Counter
|
||||
outboundRequests5XX otelmetric.Int64Counter
|
||||
outboundRequestDuration otelmetric.Float64Histogram
|
||||
)
|
||||
|
||||
// InitRequestMetrics initializes request-related metrics instruments.
|
||||
func InitRequestMetrics() error {
|
||||
if !IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
meter := GetMeter()
|
||||
var err error
|
||||
|
||||
// Inbound request metrics
|
||||
inboundRequestsTotal, err = meter.Int64Counter(
|
||||
"beckn.inbound.requests.total",
|
||||
otelmetric.WithDescription("Total number of inbound requests per host"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inboundSignValidationTotal, err = meter.Int64Counter(
|
||||
"beckn.inbound.sign_validation.total",
|
||||
otelmetric.WithDescription("Total number of inbound requests with sign validation per host"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inboundSchemaValidationTotal, err = meter.Int64Counter(
|
||||
"beckn.inbound.schema_validation.total",
|
||||
otelmetric.WithDescription("Total number of inbound requests with schema validation per host"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Outbound request metrics
|
||||
outboundRequestsTotal, err = meter.Int64Counter(
|
||||
"beckn.outbound.requests.total",
|
||||
otelmetric.WithDescription("Total number of outbound requests per host"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outboundRequests2XX, err = meter.Int64Counter(
|
||||
"beckn.outbound.requests.2xx",
|
||||
otelmetric.WithDescription("Total number of outbound requests with 2XX status code per host"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outboundRequests4XX, err = meter.Int64Counter(
|
||||
"beckn.outbound.requests.4xx",
|
||||
otelmetric.WithDescription("Total number of outbound requests with 4XX status code per host"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outboundRequests5XX, err = meter.Int64Counter(
|
||||
"beckn.outbound.requests.5xx",
|
||||
otelmetric.WithDescription("Total number of outbound requests with 5XX status code per host"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Outbound request duration histogram (for p99, p95, p75)
|
||||
outboundRequestDuration, err = meter.Float64Histogram(
|
||||
"beckn.outbound.request.duration",
|
||||
otelmetric.WithDescription("Duration of outbound requests in milliseconds"),
|
||||
otelmetric.WithUnit("ms"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordInboundRequest records an inbound request.
|
||||
func RecordInboundRequest(ctx context.Context, host string) {
|
||||
if inboundRequestsTotal == nil {
|
||||
return
|
||||
}
|
||||
inboundRequestsTotal.Add(ctx, 1, otelmetric.WithAttributes(
|
||||
attribute.String("host", host),
|
||||
))
|
||||
}
|
||||
|
||||
// RecordInboundSignValidation records an inbound request with sign validation.
|
||||
func RecordInboundSignValidation(ctx context.Context, host string) {
|
||||
if inboundSignValidationTotal == nil {
|
||||
return
|
||||
}
|
||||
inboundSignValidationTotal.Add(ctx, 1, otelmetric.WithAttributes(
|
||||
attribute.String("host", host),
|
||||
))
|
||||
}
|
||||
|
||||
// RecordInboundSchemaValidation records an inbound request with schema validation.
|
||||
func RecordInboundSchemaValidation(ctx context.Context, host string) {
|
||||
if inboundSchemaValidationTotal == nil {
|
||||
return
|
||||
}
|
||||
inboundSchemaValidationTotal.Add(ctx, 1, otelmetric.WithAttributes(
|
||||
attribute.String("host", host),
|
||||
))
|
||||
}
|
||||
|
||||
// RecordOutboundRequest records an outbound request with status code and duration.
|
||||
func RecordOutboundRequest(ctx context.Context, host string, statusCode int, duration time.Duration) {
|
||||
if outboundRequestsTotal == nil {
|
||||
return
|
||||
}
|
||||
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.String("host", host),
|
||||
attribute.String("status_code", strconv.Itoa(statusCode)),
|
||||
}
|
||||
|
||||
// Record total
|
||||
outboundRequestsTotal.Add(ctx, 1, otelmetric.WithAttributes(attrs...))
|
||||
|
||||
// Record by status code category
|
||||
statusClass := statusCode / 100
|
||||
switch statusClass {
|
||||
case 2:
|
||||
outboundRequests2XX.Add(ctx, 1, otelmetric.WithAttributes(attrs...))
|
||||
case 4:
|
||||
outboundRequests4XX.Add(ctx, 1, otelmetric.WithAttributes(attrs...))
|
||||
case 5:
|
||||
outboundRequests5XX.Add(ctx, 1, otelmetric.WithAttributes(attrs...))
|
||||
}
|
||||
|
||||
// Record duration for percentile calculations (p99, p95, p75)
|
||||
if outboundRequestDuration != nil {
|
||||
outboundRequestDuration.Record(ctx, float64(duration.Milliseconds()), otelmetric.WithAttributes(attrs...))
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPTransport wraps an http.RoundTripper to track outbound request metrics.
|
||||
type HTTPTransport struct {
|
||||
Transport http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper interface and tracks metrics.
|
||||
func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
start := time.Now()
|
||||
host := req.URL.Host
|
||||
|
||||
resp, err := t.Transport.RoundTrip(req)
|
||||
|
||||
duration := time.Since(start)
|
||||
statusCode := 0
|
||||
if resp != nil {
|
||||
statusCode = resp.StatusCode
|
||||
} else if err != nil {
|
||||
// Network error - treat as 5XX
|
||||
statusCode = 500
|
||||
}
|
||||
|
||||
RecordOutboundRequest(req.Context(), host, statusCode, duration)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// WrapHTTPTransport wraps an http.RoundTripper with metrics tracking.
|
||||
func WrapHTTPTransport(transport http.RoundTripper) http.RoundTripper {
|
||||
if !IsEnabled() {
|
||||
return transport
|
||||
}
|
||||
return &HTTPTransport{Transport: transport}
|
||||
}
|
||||
346
pkg/metrics/requests_test.go
Normal file
346
pkg/metrics/requests_test.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInitRequestMetrics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "metrics enabled",
|
||||
enabled: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "metrics disabled",
|
||||
enabled: false,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup: Initialize metrics with enabled state
|
||||
cfg := Config{
|
||||
Enabled: tt.enabled,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test InitRequestMetrics
|
||||
err = InitRequestMetrics()
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
Shutdown(context.Background())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordInboundRequest(t *testing.T) {
|
||||
// Setup
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
err = InitRequestMetrics()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
host := "example.com"
|
||||
|
||||
// Test: Record inbound request
|
||||
RecordInboundRequest(ctx, host)
|
||||
|
||||
// Verify: No error should occur
|
||||
// Note: We can't easily verify the metric value without exporting,
|
||||
// but we can verify the function doesn't panic
|
||||
assert.NotPanics(t, func() {
|
||||
RecordInboundRequest(ctx, host)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordInboundSignValidation(t *testing.T) {
|
||||
// Setup
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
err = InitRequestMetrics()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
host := "example.com"
|
||||
|
||||
// Test: Record sign validation
|
||||
RecordInboundSignValidation(ctx, host)
|
||||
|
||||
// Verify: No error should occur
|
||||
assert.NotPanics(t, func() {
|
||||
RecordInboundSignValidation(ctx, host)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordInboundSchemaValidation(t *testing.T) {
|
||||
// Setup
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
err = InitRequestMetrics()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
host := "example.com"
|
||||
|
||||
// Test: Record schema validation
|
||||
RecordInboundSchemaValidation(ctx, host)
|
||||
|
||||
// Verify: No error should occur
|
||||
assert.NotPanics(t, func() {
|
||||
RecordInboundSchemaValidation(ctx, host)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordOutboundRequest(t *testing.T) {
|
||||
// Setup
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
err = InitRequestMetrics()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
host := "example.com"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
duration time.Duration
|
||||
}{
|
||||
{
|
||||
name: "2XX status code",
|
||||
statusCode: 200,
|
||||
duration: 100 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "4XX status code",
|
||||
statusCode: 404,
|
||||
duration: 50 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "5XX status code",
|
||||
statusCode: 500,
|
||||
duration: 200 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "3XX status code",
|
||||
statusCode: 301,
|
||||
duration: 75 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test: Record outbound request
|
||||
RecordOutboundRequest(ctx, host, tt.statusCode, tt.duration)
|
||||
|
||||
// Verify: No error should occur
|
||||
assert.NotPanics(t, func() {
|
||||
RecordOutboundRequest(ctx, host, tt.statusCode, tt.duration)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTransport_RoundTrip(t *testing.T) {
|
||||
// Setup
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
err = InitRequestMetrics()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create transport wrapper
|
||||
transport := &HTTPTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req = req.WithContext(context.Background())
|
||||
|
||||
// Test: RoundTrip should track metrics
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Verify: Metrics should be recorded
|
||||
assert.NotPanics(t, func() {
|
||||
resp, err = transport.RoundTrip(req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPTransport_RoundTrip_Error(t *testing.T) {
|
||||
// Setup
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
err = InitRequestMetrics()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create transport with invalid URL to cause error
|
||||
transport := &HTTPTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
// Create request with invalid URL
|
||||
req, err := http.NewRequest("GET", "http://invalid-host-that-does-not-exist:9999", nil)
|
||||
require.NoError(t, err)
|
||||
req = req.WithContext(context.Background())
|
||||
|
||||
// Test: RoundTrip should handle error and still record metrics
|
||||
resp, err := transport.RoundTrip(req)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, resp)
|
||||
|
||||
// Verify: Metrics should still be recorded (with 500 status)
|
||||
assert.NotPanics(t, func() {
|
||||
_, _ = transport.RoundTrip(req)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWrapHTTPTransport_Enabled(t *testing.T) {
|
||||
// Setup
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
// Create a new transport
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// Test: Wrap transport
|
||||
wrapped := WrapHTTPTransport(transport)
|
||||
|
||||
// Verify: Should be wrapped
|
||||
assert.NotEqual(t, transport, wrapped)
|
||||
_, ok := wrapped.(*HTTPTransport)
|
||||
assert.True(t, ok, "Should be wrapped with HTTPTransport")
|
||||
}
|
||||
|
||||
func TestWrapHTTPTransport_Disabled(t *testing.T) {
|
||||
// Setup: Initialize metrics with disabled state
|
||||
cfg := Config{
|
||||
Enabled: false,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
// Create a new transport
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// Test: Wrap transport when metrics disabled
|
||||
wrapped := WrapHTTPTransport(transport)
|
||||
|
||||
// Verify: When metrics are disabled, IsEnabled() returns false
|
||||
// So WrapHTTPTransport should return the original transport
|
||||
// Note: This test verifies the behavior when IsEnabled() returns false
|
||||
if !IsEnabled() {
|
||||
assert.Equal(t, transport, wrapped, "Should return original transport when metrics disabled")
|
||||
} else {
|
||||
// If metrics are still enabled from previous test, just verify it doesn't panic
|
||||
assert.NotNil(t, wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordInboundRequest_WhenDisabled(t *testing.T) {
|
||||
// Setup: Metrics disabled
|
||||
cfg := Config{
|
||||
Enabled: false,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
ctx := context.Background()
|
||||
host := "example.com"
|
||||
|
||||
// Test: Should not panic when metrics are disabled
|
||||
assert.NotPanics(t, func() {
|
||||
RecordInboundRequest(ctx, host)
|
||||
RecordInboundSignValidation(ctx, host)
|
||||
RecordInboundSchemaValidation(ctx, host)
|
||||
RecordOutboundRequest(ctx, host, 200, time.Second)
|
||||
})
|
||||
}
|
||||
27
pkg/metrics/runtime.go
Normal file
27
pkg/metrics/runtime.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
otelruntime "go.opentelemetry.io/contrib/instrumentation/runtime"
|
||||
)
|
||||
|
||||
// InitRuntimeMetrics initializes Go runtime metrics instrumentation.
|
||||
// This includes CPU, memory, GC, and goroutine metrics.
|
||||
// The runtime instrumentation automatically collects:
|
||||
// - CPU usage (go_cpu_*)
|
||||
// - Memory allocation and heap stats (go_memstats_*)
|
||||
// - GC statistics (go_memstats_gc_*)
|
||||
// - Goroutine count (go_goroutines)
|
||||
func InitRuntimeMetrics() error {
|
||||
if !IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start OpenTelemetry runtime metrics collection
|
||||
// This automatically collects Go runtime metrics
|
||||
err := otelruntime.Start(otelruntime.WithMinimumReadMemStatsInterval(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
91
pkg/metrics/runtime_test.go
Normal file
91
pkg/metrics/runtime_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInitRuntimeMetrics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "metrics enabled",
|
||||
enabled: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "metrics disabled",
|
||||
enabled: false,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup: Initialize metrics with enabled state
|
||||
cfg := Config{
|
||||
Enabled: tt.enabled,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test InitRuntimeMetrics
|
||||
err = InitRuntimeMetrics()
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
Shutdown(context.Background())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRuntimeMetrics_MultipleCalls(t *testing.T) {
|
||||
// Setup
|
||||
cfg := Config{
|
||||
Enabled: true,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
// Test: Multiple calls should not cause errors
|
||||
err = InitRuntimeMetrics()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Note: Second call might fail if runtime.Start is already called,
|
||||
// but that's expected behavior
|
||||
err = InitRuntimeMetrics()
|
||||
// We don't assert on error here as it depends on internal state
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestInitRuntimeMetrics_WhenDisabled(t *testing.T) {
|
||||
// Setup: Metrics disabled
|
||||
cfg := Config{
|
||||
Enabled: false,
|
||||
ExporterType: ExporterPrometheus,
|
||||
ServiceName: "test-service",
|
||||
}
|
||||
err := InitMetrics(cfg)
|
||||
require.NoError(t, err)
|
||||
defer Shutdown(context.Background())
|
||||
|
||||
// Test: Should return nil without error when disabled
|
||||
err = InitRuntimeMetrics()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user