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