mirror of
https://github.com/hanzokv/go.git
synced 2026-09-24 00:31:51 +00:00
* Change metric type from gauge to upAndDownCounter * Fix pending requests metric * Fix go version * Bump grpc version * Fix UpDownCounter metric accounting bugs in connection pool Fix two bugs in the UpDownCounter metric instrumentation: 1. Cache miss path double-counts idle connections (High Severity): newConn() records +1 idle for every new connection, but the cache miss path in getConn() only recorded +1 used without compensating -1 idle. This caused the idle counter to grow unboundedly. Fixed by adding -1 idle compensation in the cache miss path and in the hook failure path for new connections. 2. Hook-rejected idle connection causes incorrect state accounting (Medium Severity): When a hook rejects an idle connection without error, putConnWithoutTurn calls putConn which records -1 used, +1 idle. But the connection was never transitioned to used state, causing the used counter to go negative. Fixed by recording the idle->used transition before calling putConnWithoutTurn so putConn's used->idle accounting is balanced. * Fix UpDownCounter metric gaps * fix: guard putConn metric emission against closed connections to prevent idle counter drift * fix: Non-pooled connections get wrong metric state attribution * fix: Pool Close() double-decrements in-use connection counters * fix: Monotonic counter suppressed inside pool-closed guard * fix: Shutdown race causes permanent UpDownCounter metric drift * fix: Connection closed metric double-counted during pool shutdown * fix:Race between putConn and Close causes permanent counter drift * fix:Missing metric decrement when putIdleConn delivers to closed pool --------- Co-authored-by: Nedyalko Dyakov <[email protected]>
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package redisotel
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/metric"
|
|
"go.opentelemetry.io/otel/semconv/v1.38.0/dbconv"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// Metric name constants
|
|
const (
|
|
// Operation metrics
|
|
MetricOperationDuration = "db.client.operation.duration"
|
|
|
|
// Connection metrics
|
|
MetricConnectionCount = "db.client.connection.count"
|
|
MetricConnectionCreateTime = "db.client.connection.create_time"
|
|
MetricConnectionWaitTime = "db.client.connection.wait_time"
|
|
MetricConnectionPendingReqs = "db.client.connection.pending_requests"
|
|
MetricConnectionRelaxedTimeout = "redis.client.connection.relaxed_timeout"
|
|
MetricConnectionHandoff = "redis.client.connection.handoff"
|
|
MetricConnectionClosed = "redis.client.connection.closed"
|
|
|
|
// Resiliency metrics
|
|
MetricClientErrors = "redis.client.errors"
|
|
MetricMaintenanceNotifications = "redis.client.maintenance.notifications"
|
|
|
|
// Pub/Sub metrics
|
|
MetricPubSubMessages = "redis.client.pubsub.messages"
|
|
|
|
// Stream metrics
|
|
MetricStreamLag = "redis.client.stream.lag"
|
|
|
|
// Special pool names
|
|
PoolNameMain = "main"
|
|
PoolNamePubSub = "pubsub"
|
|
)
|
|
|
|
var (
|
|
// Global observability instance
|
|
observabilityInstance *ObservabilityInstance
|
|
observabilityInstanceOnce sync.Once
|
|
)
|
|
|
|
// ObservabilityInstance manages the global observability singleton.
|
|
type ObservabilityInstance struct {
|
|
mu sync.RWMutex
|
|
config *Config
|
|
recorder *metricsRecorder
|
|
initialized bool
|
|
}
|
|
|
|
// GetObservabilityInstance returns the global observability singleton.
|
|
func GetObservabilityInstance() *ObservabilityInstance {
|
|
observabilityInstanceOnce.Do(func() {
|
|
observabilityInstance = &ObservabilityInstance{}
|
|
})
|
|
return observabilityInstance
|
|
}
|
|
|
|
// Init initializes OpenTelemetry observability globally for all Redis clients.
|
|
// This should be called once at application startup, BEFORE creating any Redis clients.
|
|
// After initialization, all Redis clients will automatically collect and export
|
|
// metrics without needing any additional configuration.
|
|
func (o *ObservabilityInstance) Init(cfg *Config) error {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
// If already initialized, return error
|
|
if o.initialized {
|
|
return errors.New("redisotel: already initialized, call Shutdown() before reinitializing")
|
|
}
|
|
|
|
o.config = cfg
|
|
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
|
|
// Get meter provider (use global if not provided)
|
|
meterProvider := cfg.MeterProvider
|
|
if meterProvider == nil {
|
|
meterProvider = otel.GetMeterProvider()
|
|
}
|
|
|
|
meter := meterProvider.Meter(
|
|
"github.com/redis/go-redis",
|
|
metric.WithInstrumentationVersion(redis.Version()),
|
|
)
|
|
|
|
internalCfg := o.configToInternal(cfg)
|
|
recorder, err := o.createRecorder(meter, internalCfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create metrics recorder: %w", err)
|
|
}
|
|
|
|
o.recorder = recorder
|
|
o.initialized = true
|
|
redis.SetOTelRecorder(recorder)
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsEnabled returns true if observability is initialized and enabled.
|
|
func (o *ObservabilityInstance) IsEnabled() bool {
|
|
o.mu.RLock()
|
|
defer o.mu.RUnlock()
|
|
return o.initialized && o.config != nil && o.config.Enabled
|
|
}
|
|
|
|
// Shutdown cleans up resources and flushes any pending metrics.
|
|
// This should be called at application shutdown.
|
|
func (o *ObservabilityInstance) Shutdown() error {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
return o.shutdownLocked()
|
|
}
|
|
|
|
func (o *ObservabilityInstance) shutdownLocked() error {
|
|
if !o.initialized {
|
|
return nil
|
|
}
|
|
|
|
redis.SetOTelRecorder(nil)
|
|
|
|
// Note: We don't shutdown the MeterProvider since it's owned by the application
|
|
// The application should call provider.Shutdown() when appropriate
|
|
|
|
o.recorder = nil
|
|
o.initialized = false
|
|
|
|
return nil
|
|
}
|
|
|
|
// configToInternal converts the public Config to internal config format
|
|
func (o *ObservabilityInstance) configToInternal(cfg *Config) config {
|
|
enabledGroups := make(map[MetricGroup]bool)
|
|
if cfg.MetricGroups&MetricGroupFlagCommand != 0 {
|
|
enabledGroups[MetricGroupCommand] = true
|
|
}
|
|
if cfg.MetricGroups&MetricGroupFlagConnectionBasic != 0 {
|
|
enabledGroups[MetricGroupConnectionBasic] = true
|
|
}
|
|
if cfg.MetricGroups&MetricGroupFlagResiliency != 0 {
|
|
enabledGroups[MetricGroupResiliency] = true
|
|
}
|
|
if cfg.MetricGroups&MetricGroupFlagConnectionAdvanced != 0 {
|
|
enabledGroups[MetricGroupConnectionAdvanced] = true
|
|
}
|
|
if cfg.MetricGroups&MetricGroupFlagPubSub != 0 {
|
|
enabledGroups[MetricGroupPubSub] = true
|
|
}
|
|
if cfg.MetricGroups&MetricGroupFlagStream != 0 {
|
|
enabledGroups[MetricGroupStream] = true
|
|
}
|
|
|
|
return config{
|
|
meterProvider: cfg.MeterProvider,
|
|
enabled: cfg.Enabled,
|
|
enabledMetricGroups: enabledGroups,
|
|
includeCommands: cfg.IncludeCommands,
|
|
excludeCommands: cfg.ExcludeCommands,
|
|
hidePubSubChannelNames: cfg.HidePubSubChannelNames,
|
|
hideStreamNames: cfg.HideStreamNames,
|
|
histAggregation: cfg.HistogramAggregation,
|
|
bucketsOperationDuration: cfg.BucketsOperationDuration,
|
|
bucketsStreamProcessingDuration: cfg.BucketsStreamLag,
|
|
bucketsConnectionCreateTime: cfg.BucketsConnectionCreateTime,
|
|
bucketsConnectionWaitTime: cfg.BucketsConnectionWaitTime,
|
|
}
|
|
}
|
|
|
|
// createRecorder creates a metricsRecorder with all instruments based on config.
|
|
func (o *ObservabilityInstance) createRecorder(meter metric.Meter, cfg config) (*metricsRecorder, error) {
|
|
var err error
|
|
|
|
var operationDuration metric.Float64Histogram
|
|
if cfg.isMetricGroupEnabled(MetricGroupCommand) {
|
|
var operationDurationOpts []metric.Float64HistogramOption
|
|
if cfg.histAggregation == HistogramAggregationExplicitBucket {
|
|
operationDurationOpts = append(operationDurationOpts,
|
|
metric.WithExplicitBucketBoundaries(cfg.bucketsOperationDuration...),
|
|
)
|
|
}
|
|
var operationDurationConv dbconv.ClientOperationDuration
|
|
operationDurationConv, err = dbconv.NewClientOperationDuration(meter, operationDurationOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create operation duration histogram: %w", err)
|
|
}
|
|
operationDuration = operationDurationConv.Inst()
|
|
}
|
|
|
|
var connectionCount metric.Int64UpDownCounter // OTel semconv: UpDownCounter
|
|
var connectionCreateTime metric.Float64Histogram
|
|
var connectionRelaxedTimeout metric.Int64UpDownCounter
|
|
var connectionHandoff metric.Int64Counter
|
|
|
|
if cfg.isMetricGroupEnabled(MetricGroupConnectionBasic) {
|
|
connectionCount, err = meter.Int64UpDownCounter(
|
|
dbconv.ClientConnectionCount{}.Name(),
|
|
metric.WithDescription(dbconv.ClientConnectionCount{}.Description()),
|
|
metric.WithUnit(dbconv.ClientConnectionCount{}.Unit()),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create connection count metric: %w", err)
|
|
}
|
|
|
|
var connectionCreateTimeOpts []metric.Float64HistogramOption
|
|
if cfg.histAggregation == HistogramAggregationExplicitBucket {
|
|
connectionCreateTimeOpts = append(connectionCreateTimeOpts,
|
|
metric.WithExplicitBucketBoundaries(cfg.bucketsConnectionCreateTime...),
|
|
)
|
|
}
|
|
var connectionCreateTimeConv dbconv.ClientConnectionCreateTime
|
|
connectionCreateTimeConv, err = dbconv.NewClientConnectionCreateTime(meter, connectionCreateTimeOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create connection create time histogram: %w", err)
|
|
}
|
|
connectionCreateTime = connectionCreateTimeConv.Inst()
|
|
|
|
connectionRelaxedTimeout, err = meter.Int64UpDownCounter(
|
|
MetricConnectionRelaxedTimeout,
|
|
metric.WithDescription("How many times the connection timeout has been increased/decreased (after a server maintenance notification)"),
|
|
metric.WithUnit("{relaxation}"),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create connection relaxed timeout metric: %w", err)
|
|
}
|
|
|
|
connectionHandoff, err = meter.Int64Counter(
|
|
MetricConnectionHandoff,
|
|
metric.WithDescription("Connections that have been handed off to another node (e.g after a MOVING notification)"),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create connection handoff metric: %w", err)
|
|
}
|
|
}
|
|
|
|
var clientErrors metric.Int64Counter
|
|
var maintenanceNotifications metric.Int64Counter
|
|
|
|
if cfg.isMetricGroupEnabled(MetricGroupResiliency) {
|
|
clientErrors, err = meter.Int64Counter(
|
|
MetricClientErrors,
|
|
metric.WithDescription("Number of errors handled by the Redis client"),
|
|
metric.WithUnit("{error}"),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create client errors metric: %w", err)
|
|
}
|
|
|
|
maintenanceNotifications, err = meter.Int64Counter(
|
|
MetricMaintenanceNotifications,
|
|
metric.WithDescription("Number of maintenance notifications received"),
|
|
metric.WithUnit("{notification}"),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create maintenance notifications metric: %w", err)
|
|
}
|
|
}
|
|
|
|
var connectionWaitTime metric.Float64Histogram
|
|
var connectionClosed metric.Int64Counter
|
|
var connectionPendingReqs metric.Int64UpDownCounter // OTel semconv: UpDownCounter
|
|
|
|
if cfg.isMetricGroupEnabled(MetricGroupConnectionAdvanced) {
|
|
var connectionWaitTimeOpts []metric.Float64HistogramOption
|
|
if cfg.histAggregation == HistogramAggregationExplicitBucket {
|
|
connectionWaitTimeOpts = append(connectionWaitTimeOpts,
|
|
metric.WithExplicitBucketBoundaries(cfg.bucketsConnectionWaitTime...),
|
|
)
|
|
}
|
|
var connectionWaitTimeConv dbconv.ClientConnectionWaitTime
|
|
connectionWaitTimeConv, err = dbconv.NewClientConnectionWaitTime(meter, connectionWaitTimeOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create connection wait time histogram: %w", err)
|
|
}
|
|
connectionWaitTime = connectionWaitTimeConv.Inst()
|
|
|
|
connectionClosed, err = meter.Int64Counter(
|
|
MetricConnectionClosed,
|
|
metric.WithDescription("The number of connections that have been closed"),
|
|
metric.WithUnit("{connection}"),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create connection closed metric: %w", err)
|
|
}
|
|
|
|
connectionPendingReqs, err = meter.Int64UpDownCounter(
|
|
dbconv.ClientConnectionPendingRequests{}.Name(),
|
|
metric.WithDescription(dbconv.ClientConnectionPendingRequests{}.Description()),
|
|
metric.WithUnit(dbconv.ClientConnectionPendingRequests{}.Unit()),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create connection pending requests metric: %w", err)
|
|
}
|
|
}
|
|
|
|
var pubsubMessages metric.Int64Counter
|
|
|
|
if cfg.isMetricGroupEnabled(MetricGroupPubSub) {
|
|
pubsubMessages, err = meter.Int64Counter(
|
|
MetricPubSubMessages,
|
|
metric.WithDescription("The number of Pub/Sub messages sent or received"),
|
|
metric.WithUnit("{message}"),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Pub/Sub messages metric: %w", err)
|
|
}
|
|
}
|
|
|
|
var streamLag metric.Float64Histogram
|
|
|
|
if cfg.isMetricGroupEnabled(MetricGroupStream) {
|
|
var streamLagOpts []metric.Float64HistogramOption
|
|
streamLagOpts = append(streamLagOpts,
|
|
metric.WithDescription("The lag between message creation and consumption in a stream consumer group"),
|
|
metric.WithUnit("s"),
|
|
)
|
|
if cfg.histAggregation == HistogramAggregationExplicitBucket {
|
|
streamLagOpts = append(streamLagOpts,
|
|
metric.WithExplicitBucketBoundaries(cfg.bucketsStreamProcessingDuration...),
|
|
)
|
|
}
|
|
streamLag, err = meter.Float64Histogram(
|
|
MetricStreamLag,
|
|
streamLagOpts...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create stream lag histogram: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create recorder
|
|
recorder := &metricsRecorder{
|
|
operationDuration: operationDuration,
|
|
connectionCount: connectionCount,
|
|
connectionCreateTime: connectionCreateTime,
|
|
connectionRelaxedTimeout: connectionRelaxedTimeout,
|
|
connectionHandoff: connectionHandoff,
|
|
clientErrors: clientErrors,
|
|
maintenanceNotifications: maintenanceNotifications,
|
|
connectionWaitTime: connectionWaitTime,
|
|
connectionClosed: connectionClosed,
|
|
connectionPendingReqs: connectionPendingReqs,
|
|
pubsubMessages: pubsubMessages,
|
|
streamLag: streamLag,
|
|
cfg: &cfg,
|
|
}
|
|
|
|
return recorder, nil
|
|
}
|
|
|
|
// parsePoolName extracts server address, port, and database index from a pool name.
|
|
// Pool name format: "host:port/db" or "host/db" or "host:port" or "host"
|
|
// Returns: (serverAddr, serverPort, dbIndex)
|
|
func parsePoolName(poolName string) (string, string, string) {
|
|
// Handle special pool names
|
|
if poolName == PoolNameMain || poolName == PoolNamePubSub {
|
|
return "", "", ""
|
|
}
|
|
|
|
parts := strings.Split(poolName, "/")
|
|
addrPart := parts[0]
|
|
dbIndex := ""
|
|
if len(parts) > 1 {
|
|
dbIndex = parts[1]
|
|
}
|
|
host, port := parseAddr(addrPart)
|
|
return host, port, dbIndex
|
|
}
|