Voice et bot modif

This commit is contained in:
pi 2026-06-16 17:09:34 +00:00
parent 189d56026b
commit 7333a22bcd
10774 changed files with 634644 additions and 933308 deletions

View file

@ -0,0 +1,27 @@
"""
OpenTelemetry observability module for redis-py.
This module provides APIs for collecting and exporting Redis metrics using OpenTelemetry.
Usage:
from redis.observability import get_observability_instance, OTelConfig
otel = get_observability_instance()
otel.init(OTelConfig())
"""
from redis.observability.config import MetricGroup, OTelConfig, TelemetryOption
from redis.observability.providers import (
ObservabilityInstance,
get_observability_instance,
reset_observability_instance,
)
__all__ = [
"OTelConfig",
"MetricGroup",
"TelemetryOption",
"ObservabilityInstance",
"get_observability_instance",
"reset_observability_instance",
]

View file

@ -0,0 +1,428 @@
"""
OpenTelemetry semantic convention attributes for Redis.
This module provides constants and helper functions for building OTel attributes
according to the semantic conventions for database clients.
Reference: https://opentelemetry.io/docs/specs/semconv/database/redis/
"""
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
import redis
if TYPE_CHECKING:
from redis.asyncio.connection import ConnectionPool
from redis.asyncio.multidb.database import AsyncDatabase
from redis.connection import ConnectionPoolInterface
from redis.multidb.database import SyncDatabase
# Database semantic convention attributes
DB_SYSTEM = "db.system"
DB_NAMESPACE = "db.namespace"
DB_OPERATION_NAME = "db.operation.name"
DB_RESPONSE_STATUS_CODE = "db.response.status_code"
DB_STORED_PROCEDURE_NAME = "db.stored_procedure.name"
# Error attributes
ERROR_TYPE = "error.type"
# Network attributes
NETWORK_PEER_ADDRESS = "network.peer.address"
NETWORK_PEER_PORT = "network.peer.port"
# Server attributes
SERVER_ADDRESS = "server.address"
SERVER_PORT = "server.port"
# Connection pool attributes
DB_CLIENT_CONNECTION_POOL_NAME = "db.client.connection.pool.name"
DB_CLIENT_CONNECTION_STATE = "db.client.connection.state"
DB_CLIENT_CONNECTION_NAME = "db.client.connection.name"
# Geofailover attributes
DB_CLIENT_GEOFAILOVER_FAIL_FROM = "db.client.geofailover.fail_from"
DB_CLIENT_GEOFAILOVER_FAIL_TO = "db.client.geofailover.fail_to"
DB_CLIENT_GEOFAILOVER_REASON = "db.client.geofailover.reason"
# Redis-specific attributes
REDIS_CLIENT_LIBRARY = "redis.client.library"
REDIS_CLIENT_CONNECTION_PUBSUB = "redis.client.connection.pubsub"
REDIS_CLIENT_CONNECTION_CLOSE_REASON = "redis.client.connection.close.reason"
REDIS_CLIENT_CONNECTION_NOTIFICATION = "redis.client.connection.notification"
REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS = "redis.client.operation.retry_attempts"
REDIS_CLIENT_OPERATION_BLOCKING = "redis.client.operation.blocking"
REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION = "redis.client.pubsub.message.direction"
REDIS_CLIENT_PUBSUB_CHANNEL = "redis.client.pubsub.channel"
REDIS_CLIENT_PUBSUB_SHARDED = "redis.client.pubsub.sharded"
REDIS_CLIENT_ERROR_INTERNAL = "redis.client.errors.internal"
REDIS_CLIENT_ERROR_CATEGORY = "redis.client.errors.category"
REDIS_CLIENT_STREAM_NAME = "redis.client.stream.name"
REDIS_CLIENT_CONSUMER_GROUP = "redis.client.consumer_group"
REDIS_CLIENT_CSC_RESULT = "redis.client.csc.result"
REDIS_CLIENT_CSC_REASON = "redis.client.csc.reason"
class ConnectionState(Enum):
IDLE = "idle"
USED = "used"
class PubSubDirection(Enum):
PUBLISH = "publish"
RECEIVE = "receive"
class CSCResult(Enum):
HIT = "hit"
MISS = "miss"
class CSCReason(Enum):
FULL = "full"
INVALIDATION = "invalidation"
class GeoFailoverReason(Enum):
AUTOMATIC = "automatic"
MANUAL = "manual"
class AttributeBuilder:
"""
Helper class to build OTel semantic convention attributes for Redis operations.
"""
@staticmethod
def build_base_attributes(
server_address: Optional[str] = None,
server_port: Optional[int] = None,
db_namespace: Optional[int] = None,
) -> Dict[str, Any]:
"""
Build base attributes common to all Redis operations.
Args:
server_address: Redis server address (FQDN or IP)
server_port: Redis server port
db_namespace: Redis database index
Returns:
Dictionary of base attributes
"""
attrs: Dict[str, Any] = {
DB_SYSTEM: "redis",
REDIS_CLIENT_LIBRARY: f"redis-py:v{redis.__version__}",
}
if server_address is not None:
attrs[SERVER_ADDRESS] = server_address
if server_port is not None:
attrs[SERVER_PORT] = server_port
if db_namespace is not None:
attrs[DB_NAMESPACE] = str(db_namespace)
return attrs
@staticmethod
def build_operation_attributes(
command_name: Optional[Union[str, bytes]] = None,
batch_size: Optional[int] = None, # noqa
network_peer_address: Optional[str] = None,
network_peer_port: Optional[int] = None,
stored_procedure_name: Optional[str] = None,
retry_attempts: Optional[int] = None,
is_blocking: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Build attributes for a Redis operation (command execution).
Args:
command_name: Redis command name (e.g., 'GET', 'SET', 'MULTI'), can be str or bytes
batch_size: Number of commands in batch (for pipelines/transactions)
network_peer_address: Resolved peer address
network_peer_port: Peer port number
stored_procedure_name: Lua script name or SHA1 digest
retry_attempts: Number of retry attempts made
is_blocking: Whether the operation is a blocking command
Returns:
Dictionary of operation attributes
"""
attrs: Dict[str, Any] = {}
if command_name is not None:
# Ensure command_name is a string (it can be bytes from args[0])
if isinstance(command_name, bytes):
command_name = command_name.decode("utf-8", errors="replace")
attrs[DB_OPERATION_NAME] = command_name.upper()
if network_peer_address is not None:
attrs[NETWORK_PEER_ADDRESS] = network_peer_address
if network_peer_port is not None:
attrs[NETWORK_PEER_PORT] = network_peer_port
if stored_procedure_name is not None:
attrs[DB_STORED_PROCEDURE_NAME] = stored_procedure_name
if retry_attempts is not None and retry_attempts > 0:
attrs[REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS] = retry_attempts
if is_blocking is not None:
attrs[REDIS_CLIENT_OPERATION_BLOCKING] = is_blocking
return attrs
@staticmethod
def build_connection_attributes(
pool_name: Optional[str] = None,
connection_state: Optional[ConnectionState] = None,
connection_name: Optional[str] = None,
is_pubsub: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Build attributes for connection pool metrics.
Args:
pool_name: Unique connection pool name
connection_state: Connection state ('idle' or 'used')
is_pubsub: Whether this is a PubSub connection
connection_name: Unique connection name
Returns:
Dictionary of connection pool attributes
"""
attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()
if pool_name is not None:
attrs[DB_CLIENT_CONNECTION_POOL_NAME] = pool_name
if connection_state is not None:
attrs[DB_CLIENT_CONNECTION_STATE] = connection_state.value
if is_pubsub is not None:
attrs[REDIS_CLIENT_CONNECTION_PUBSUB] = is_pubsub
if connection_name is not None:
attrs[DB_CLIENT_CONNECTION_NAME] = connection_name
return attrs
@staticmethod
def build_error_attributes(
error_type: Optional[Exception] = None,
is_internal: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Build error attributes.
Args:
is_internal: Whether the error is internal (e.g., timeout, network error)
error_type: The exception that occurred
Returns:
Dictionary of error attributes
"""
attrs: Dict[str, Any] = {}
if error_type is not None:
attrs[ERROR_TYPE] = error_type.__class__.__name__
if (
hasattr(error_type, "status_code")
and error_type.status_code is not None
):
attrs[DB_RESPONSE_STATUS_CODE] = error_type.status_code
else:
attrs[DB_RESPONSE_STATUS_CODE] = "error"
if hasattr(error_type, "error_type") and error_type.error_type is not None:
attrs[REDIS_CLIENT_ERROR_CATEGORY] = error_type.error_type.value
else:
attrs[REDIS_CLIENT_ERROR_CATEGORY] = "other"
if is_internal is not None:
attrs[REDIS_CLIENT_ERROR_INTERNAL] = is_internal
return attrs
@staticmethod
def build_pubsub_message_attributes(
direction: PubSubDirection,
channel: Optional[str] = None,
sharded: Optional[bool] = None,
) -> Dict[str, Any]:
"""
Build attributes for a PubSub message.
Args:
direction: Message direction ('publish' or 'receive')
channel: Pub/Sub channel name
sharded: True if sharded Pub/Sub channel
Returns:
Dictionary of PubSub message attributes
"""
attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()
attrs[REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION] = direction.value
if channel is not None:
attrs[REDIS_CLIENT_PUBSUB_CHANNEL] = channel
if sharded is not None:
attrs[REDIS_CLIENT_PUBSUB_SHARDED] = sharded
return attrs
@staticmethod
def build_streaming_attributes(
stream_name: Optional[str] = None,
consumer_group: Optional[str] = None,
consumer_name: Optional[str] = None, # noqa
) -> Dict[str, Any]:
"""
Build attributes for a streaming operation.
Args:
stream_name: Name of the stream
consumer_group: Name of the consumer group
consumer_name: Name of the consumer
Returns:
Dictionary of streaming attributes
"""
attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()
if stream_name is not None:
attrs[REDIS_CLIENT_STREAM_NAME] = stream_name
if consumer_group is not None:
attrs[REDIS_CLIENT_CONSUMER_GROUP] = consumer_group
return attrs
@staticmethod
def build_csc_attributes(
pool_name: Optional[str] = None,
result: Optional[CSCResult] = None,
reason: Optional[CSCReason] = None,
) -> Dict[str, Any]:
"""
Build attributes for a Client Side Caching (CSC) operation.
Args:
pool_name: Connection pool name (used only for csc_items metric)
result: CSC result ('hit' or 'miss')
reason: Reason for CSC eviction ('full' or 'invalidation')
Returns:
Dictionary of CSC attributes
"""
attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()
if pool_name is not None:
attrs[DB_CLIENT_CONNECTION_POOL_NAME] = pool_name
if result is not None:
attrs[REDIS_CLIENT_CSC_RESULT] = result.value
if reason is not None:
attrs[REDIS_CLIENT_CSC_REASON] = reason.value
return attrs
@staticmethod
def build_geo_failover_attributes(
fail_from: Union["SyncDatabase", "AsyncDatabase"],
fail_to: Union["SyncDatabase", "AsyncDatabase"],
reason: GeoFailoverReason,
) -> Dict[str, Any]:
"""
Build attributes for a geo failover.
Args:
fail_from: Database failed from
fail_to: Database failed to
reason: Reason for the failover
Returns:
Dictionary of geo failover attributes
"""
attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()
attrs[DB_CLIENT_GEOFAILOVER_FAIL_FROM] = get_db_name(fail_from)
attrs[DB_CLIENT_GEOFAILOVER_FAIL_TO] = get_db_name(fail_to)
attrs[DB_CLIENT_GEOFAILOVER_REASON] = reason.value
return attrs
@staticmethod
def build_pool_name(
server_address: str,
server_port: int,
db_namespace: int = 0,
) -> str:
"""
Build a unique connection pool name.
Args:
server_address: Redis server address
server_port: Redis server port
db_namespace: Redis database index
Returns:
Unique pool name in format "address:port/db"
"""
return f"{server_address}:{server_port}/{db_namespace}"
def get_pool_name(pool: Union["ConnectionPoolInterface", "ConnectionPool"]) -> str:
"""
Get a short string representation of a connection pool for observability.
This provides a concise pool identifier suitable for use as a metric attribute,
in the format: host:port_uniqueID (matching go-redis format)
Args:
pool: Connection pool instance
Returns:
Short pool name in format "host:port_uniqueID"
Example:
>>> pool = ConnectionPool(host='localhost', port=6379, db=0)
>>> get_pool_name(pool)
'localhost:6379_a1b2c3d4'
"""
host = pool.connection_kwargs.get("host", "unknown")
port = pool.connection_kwargs.get("port", 6379)
# Get unique pool ID if available (added for observability)
pool_id = getattr(pool, "_pool_id", "")
if pool_id:
return f"{host}:{port}_{pool_id}"
else:
return f"{host}:{port}"
def get_db_name(database: Union["SyncDatabase", "AsyncDatabase"]):
"""
Get a short string representation of a database for observability.
Args:
database: Database instance
Returns:
Short database name in format "{host}:{port}/{weight}"
"""
host = database.client.get_connection_kwargs()["host"]
port = database.client.get_connection_kwargs()["port"]
weight = database.weight
return f"{host}:{port}/{weight}"

View file

@ -0,0 +1,181 @@
from enum import IntFlag, auto
from typing import List, Optional, Sequence
"""
OpenTelemetry configuration for redis-py.
This module handles configuration for OTel observability features,
including parsing environment variables and validating settings.
"""
class MetricGroup(IntFlag):
"""Metric groups that can be enabled/disabled."""
RESILIENCY = auto()
CONNECTION_BASIC = auto()
CONNECTION_ADVANCED = auto()
COMMAND = auto()
CSC = auto()
STREAMING = auto()
PUBSUB = auto()
class TelemetryOption(IntFlag):
"""Telemetry options to export."""
METRICS = auto()
def default_operation_duration_buckets() -> Sequence[float]:
return [
0.0001,
0.00025,
0.0005,
0.001,
0.0025,
0.005,
0.01,
0.025,
0.05,
0.1,
0.25,
0.5,
1,
2.5,
]
def default_histogram_buckets() -> Sequence[float]:
return [0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10]
class OTelConfig:
"""
Configuration for OpenTelemetry observability in redis-py.
This class manages all OTel-related settings including metrics, traces (future),
and logs (future). Configuration can be provided via constructor parameters or
environment variables (OTEL_* spec).
Constructor parameters take precedence over environment variables.
Args:
enabled_telemetry: Enabled telemetry options to export (default: metrics). Traces and logs will be added
in future phases.
metric_groups: Group of metrics that should be exported.
include_commands: Explicit allowlist of commands to track
exclude_commands: Blocklist of commands to track
hide_pubsub_channel_names: If True, hide PubSub channel names in metrics (default: False)
hide_stream_names: If True, hide stream names in streaming metrics (default: False)
Note:
Redis-py uses the global MeterProvider set by your application.
Set it up before initializing observability:
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics._internal.view import View
from opentelemetry.sdk.metrics._internal.aggregation import ExplicitBucketHistogramAggregation
# Configure histogram bucket boundaries via Views
views = [
View(
instrument_name="db.client.operation.duration",
aggregation=ExplicitBucketHistogramAggregation(
boundaries=[0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005,
0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5]
),
),
# Add more views for other histograms...
]
provider = MeterProvider(views=views, metric_readers=[reader])
metrics.set_meter_provider(provider)
# Then initialize redis-py observability
from redis.observability import get_observability_instance, OTelConfig
otel = get_observability_instance()
otel.init(OTelConfig())
"""
DEFAULT_TELEMETRY = TelemetryOption.METRICS
DEFAULT_METRIC_GROUPS = MetricGroup.CONNECTION_BASIC | MetricGroup.RESILIENCY
def __init__(
self,
# Core enablement
enabled_telemetry: Optional[List[TelemetryOption]] = None,
# Metrics-specific
metric_groups: Optional[List[MetricGroup]] = None,
# Redis-specific telemetry controls
include_commands: Optional[List[str]] = None,
exclude_commands: Optional[List[str]] = None,
# Privacy controls
hide_pubsub_channel_names: bool = False,
hide_stream_names: bool = False,
# Bucket sizes
buckets_operation_duration: Sequence[
float
] = default_operation_duration_buckets(),
buckets_stream_processing_duration: Sequence[
float
] = default_histogram_buckets(),
buckets_connection_create_time: Sequence[float] = default_histogram_buckets(),
buckets_connection_wait_time: Sequence[float] = default_histogram_buckets(),
):
# Core enablement
if enabled_telemetry is None:
self.enabled_telemetry = self.DEFAULT_TELEMETRY
else:
self.enabled_telemetry = TelemetryOption(0)
for option in enabled_telemetry:
self.enabled_telemetry |= option
# Enable default metrics if None given
if metric_groups is None:
self.metric_groups = self.DEFAULT_METRIC_GROUPS
else:
self.metric_groups = MetricGroup(0)
for metric_group in metric_groups:
self.metric_groups |= metric_group
# Redis-specific controls
self.include_commands = set(include_commands) if include_commands else None
self.exclude_commands = set(exclude_commands) if exclude_commands else set()
# Privacy controls for hiding sensitive names in metrics
self.hide_pubsub_channel_names = hide_pubsub_channel_names
self.hide_stream_names = hide_stream_names
# Bucket sizes
self.buckets_operation_duration = buckets_operation_duration
self.buckets_stream_processing_duration = buckets_stream_processing_duration
self.buckets_connection_create_time = buckets_connection_create_time
self.buckets_connection_wait_time = buckets_connection_wait_time
def is_enabled(self) -> bool:
"""Check if any observability feature is enabled."""
return bool(self.enabled_telemetry)
def should_track_command(self, command_name: str) -> bool:
"""
Determine if a command should be tracked based on include/exclude lists.
Args:
command_name: The Redis command name (e.g., 'GET', 'SET')
Returns:
True if the command should be tracked, False otherwise
"""
command_upper = command_name.upper()
# If include list is specified, only track commands in the list
if self.include_commands is not None:
return command_upper in self.include_commands
# Otherwise, track all commands except those in exclude list
return command_upper not in self.exclude_commands
def __repr__(self) -> str:
return f"OTelConfig(enabled_telemetry={self.enabled_telemetry}"

View file

@ -0,0 +1,727 @@
"""
OpenTelemetry metrics collector for redis-py.
This module defines and manages all metric instruments according to
OTel semantic conventions for database clients.
"""
import logging
import time
from enum import Enum
from typing import TYPE_CHECKING, Callable, Optional, Union
if TYPE_CHECKING:
from redis.asyncio.connection import ConnectionPool
from redis.asyncio.multidb.database import AsyncDatabase
from redis.connection import ConnectionPoolInterface
from redis.multidb.database import SyncDatabase
from redis.observability.attributes import (
REDIS_CLIENT_CONNECTION_CLOSE_REASON,
REDIS_CLIENT_CONNECTION_NOTIFICATION,
AttributeBuilder,
ConnectionState,
CSCReason,
CSCResult,
GeoFailoverReason,
PubSubDirection,
get_pool_name,
)
from redis.observability.config import MetricGroup, OTelConfig
from redis.utils import deprecated_args, deprecated_function
logger = logging.getLogger(__name__)
# Optional imports - OTel SDK may not be installed
try:
from opentelemetry.metrics import Meter
OTEL_AVAILABLE = True
except ImportError:
OTEL_AVAILABLE = False
Counter = None
Histogram = None
Meter = None
UpDownCounter = None
class CloseReason(Enum):
"""
Enum representing the reason why a Redis client connection was closed.
Values:
APPLICATION_CLOSE: The connection was closed intentionally by the application
(for example, during normal shutdown or explicit cleanup).
ERROR: The connection was closed due to an unexpected error
(for example, network failure or protocol error).
HEALTHCHECK_FAILED: The connection was closed because a health check
or liveness check for the connection failed.
"""
APPLICATION_CLOSE = "application_close"
ERROR = "error"
HEALTHCHECK_FAILED = "healthcheck_failed"
class RedisMetricsCollector:
"""
Collects and records OpenTelemetry metrics for Redis operations.
This class manages all metric instruments and provides methods to record
various Redis operations including connection pool events, command execution,
and cluster-specific operations.
Args:
meter: OpenTelemetry Meter instance
config: OTel configuration object
"""
METER_NAME = "redis-py"
METER_VERSION = "1.0.0"
def __init__(self, meter: Meter, config: OTelConfig):
if not OTEL_AVAILABLE:
raise ImportError(
"OpenTelemetry API is not installed. "
"Install it with: pip install opentelemetry-api"
)
self.meter = meter
self.config = config
self.attr_builder = AttributeBuilder()
# Initialize enabled metric instruments
if MetricGroup.RESILIENCY in self.config.metric_groups:
self._init_resiliency_metrics()
if MetricGroup.COMMAND in self.config.metric_groups:
self._init_command_metrics()
if MetricGroup.CONNECTION_BASIC in self.config.metric_groups:
self._init_connection_basic_metrics()
if MetricGroup.CONNECTION_ADVANCED in self.config.metric_groups:
self._init_connection_advanced_metrics()
if MetricGroup.PUBSUB in self.config.metric_groups:
self._init_pubsub_metrics()
if MetricGroup.STREAMING in self.config.metric_groups:
self._init_streaming_metrics()
if MetricGroup.CSC in self.config.metric_groups:
self._init_csc_metrics()
logger.info("RedisMetricsCollector initialized")
def _init_resiliency_metrics(self) -> None:
"""Initialize resiliency metrics."""
self.client_errors = self.meter.create_counter(
name="redis.client.errors",
unit="{error}",
description="A counter of all errors (both returned to the user and handled internally in the client library)",
)
self.maintenance_notifications = self.meter.create_counter(
name="redis.client.maintenance.notifications",
unit="{notification}",
description="Tracks server-side maintenance notifications",
)
self.geo_failovers = self.meter.create_counter(
name="redis.client.geofailover.failovers",
unit="{geofailover}",
description="Total count of failovers happened using MultiDbClient.",
)
def _init_connection_basic_metrics(self) -> None:
"""Initialize basic connection metrics."""
self.connection_create_time = self.meter.create_histogram(
name="db.client.connection.create_time",
unit="s",
description="Time to create a new connection",
explicit_bucket_boundaries_advisory=self.config.buckets_connection_create_time,
)
self.connection_relaxed_timeout = self.meter.create_up_down_counter(
name="redis.client.connection.relaxed_timeout",
unit="{relaxation}",
description="Counts up for relaxed timeout, counts down for unrelaxed timeout",
)
self.connection_handoff = self.meter.create_counter(
name="redis.client.connection.handoff",
unit="{handoff}",
description="Connections that have been handed off (e.g., after a MOVING notification)",
)
# DEPRECATED: This attribute is kept for backward compatibility.
# It requires manual initialization via init_connection_count() with a callback.
# Use connection_count_updown instead for push-based tracking.
# Will be removed in the next major version.
self.connection_count = None
# New push-based connection count tracking via UpDownCounter
self.connection_count_updown = self.meter.create_up_down_counter(
name="db.client.connection.count",
unit="{connection}",
description="Number of connections currently in the pool by state",
)
def _init_connection_advanced_metrics(self) -> None:
"""Initialize advanced connection metrics."""
self.connection_timeouts = self.meter.create_counter(
name="db.client.connection.timeouts",
unit="{timeout}",
description="The number of connection timeouts that have occurred trying to obtain a connection from the pool.",
)
self.connection_wait_time = self.meter.create_histogram(
name="db.client.connection.wait_time",
unit="s",
description="Time to obtain an open connection from the pool",
explicit_bucket_boundaries_advisory=self.config.buckets_connection_wait_time,
)
self.connection_closed = self.meter.create_counter(
name="redis.client.connection.closed",
unit="{connection}",
description="Total number of closed connections",
)
def _init_command_metrics(self) -> None:
"""Initialize command execution metric instruments."""
self.operation_duration = self.meter.create_histogram(
name="db.client.operation.duration",
unit="s",
description="Command execution duration",
explicit_bucket_boundaries_advisory=self.config.buckets_operation_duration,
)
def _init_pubsub_metrics(self) -> None:
"""Initialize PubSub metric instruments."""
self.pubsub_messages = self.meter.create_counter(
name="redis.client.pubsub.messages",
unit="{message}",
description="Tracks published and received messages",
)
def _init_streaming_metrics(self) -> None:
"""Initialize Streaming metric instruments."""
self.stream_lag = self.meter.create_histogram(
name="redis.client.stream.lag",
unit="s",
description="End-to-end lag per message, showing how stale are the messages when the application starts processing them.",
explicit_bucket_boundaries_advisory=self.config.buckets_stream_processing_duration,
)
def _init_csc_metrics(self) -> None:
"""Initialize Client Side Caching (CSC) metric instruments."""
self.csc_requests = self.meter.create_counter(
name="redis.client.csc.requests",
unit="{request}",
description="The total number of requests to the cache",
)
self.csc_evictions = self.meter.create_counter(
name="redis.client.csc.evictions",
unit="{eviction}",
description="The total number of cache evictions",
)
self.csc_network_saved = self.meter.create_counter(
name="redis.client.csc.network_saved",
unit="By",
description="The total number of bytes saved by using CSC",
)
# Resiliency metric recording methods
def record_error_count(
self,
server_address: Optional[str] = None,
server_port: Optional[int] = None,
network_peer_address: Optional[str] = None,
network_peer_port: Optional[int] = None,
error_type: Optional[Exception] = None,
retry_attempts: Optional[int] = None,
is_internal: Optional[bool] = None,
):
"""
Record error count
Args:
server_address: Server address
server_port: Server port
network_peer_address: Network peer address
network_peer_port: Network peer port
error_type: Error type
retry_attempts: Retry attempts
is_internal: Whether the error is internal (e.g., timeout, network error)
"""
if not hasattr(self, "client_errors"):
return
attrs = self.attr_builder.build_base_attributes(
server_address=server_address,
server_port=server_port,
)
attrs.update(
self.attr_builder.build_operation_attributes(
network_peer_address=network_peer_address,
network_peer_port=network_peer_port,
retry_attempts=retry_attempts,
)
)
attrs.update(
self.attr_builder.build_error_attributes(
error_type=error_type,
is_internal=is_internal,
)
)
self.client_errors.add(1, attributes=attrs)
def record_maint_notification_count(
self,
server_address: str,
server_port: int,
network_peer_address: str,
network_peer_port: int,
maint_notification: str,
):
"""
Record maintenance notification count
Args:
server_address: Server address
server_port: Server port
network_peer_address: Network peer address
network_peer_port: Network peer port
maint_notification: Maintenance notification
"""
if not hasattr(self, "maintenance_notifications"):
return
attrs = self.attr_builder.build_base_attributes(
server_address=server_address,
server_port=server_port,
)
attrs.update(
self.attr_builder.build_operation_attributes(
network_peer_address=network_peer_address,
network_peer_port=network_peer_port,
)
)
attrs[REDIS_CLIENT_CONNECTION_NOTIFICATION] = maint_notification
self.maintenance_notifications.add(1, attributes=attrs)
def record_geo_failover(
self,
fail_from: Union["SyncDatabase", "AsyncDatabase"],
fail_to: Union["SyncDatabase", "AsyncDatabase"],
reason: GeoFailoverReason,
):
"""
Record geo failover
Args:
fail_from: Database failed from
fail_to: Database failed to
reason: Reason for the failover
"""
if not hasattr(self, "geo_failovers"):
return
attrs = self.attr_builder.build_geo_failover_attributes(
fail_from=fail_from,
fail_to=fail_to,
reason=reason,
)
return self.geo_failovers.add(1, attributes=attrs)
def record_connection_count(
self,
pool_name: str,
connection_state: ConnectionState,
counter: int = 1,
) -> None:
"""
Record a connection count change for a single state.
Args:
pool_name: Connection pool name
connection_state: State to update (IDLE or USED)
counter: Number to add (positive) or subtract (negative)
"""
if not hasattr(self, "connection_count_updown"):
return
attrs = self.attr_builder.build_connection_attributes(
pool_name=pool_name,
connection_state=connection_state,
)
self.connection_count_updown.add(counter, attributes=attrs)
@deprecated_function(
reason="Connection count is now tracked via record_connection_count(). "
"This functionality will be removed in the next major version",
version="7.4.0",
)
def init_connection_count(
self,
callback: Callable,
) -> None:
"""
Initialize observable gauge for connection count metric.
Args:
callback: Callback function to retrieve connection counts
"""
if MetricGroup.CONNECTION_BASIC not in self.config.metric_groups:
return
# DEPRECATED: Create observable gauge for backward compatibility
# This gauge uses a different metric name to avoid conflicts with
# the new push-based connection_count_updown counter
self.connection_count = self.meter.create_observable_gauge(
name="db.client.connection.count.deprecated",
unit="{connection}",
description="The number of connections that are currently in state "
"described by the state attribute (deprecated - use db.client.connection.count instead)",
callbacks=[callback],
)
def init_csc_items(
self,
callback: Callable,
) -> None:
"""
Initialize observable gauge for CSC items metric.
Args:
callback: Callback function to retrieve CSC items count
"""
if MetricGroup.CSC not in self.config.metric_groups and not self.csc_items:
return
self.csc_items = self.meter.create_observable_gauge(
name="redis.client.csc.items",
unit="{item}",
description="The total number of cached responses currently stored",
callbacks=[callback],
)
def record_connection_timeout(self, pool_name: str) -> None:
"""
Record a connection timeout event.
Args:
pool_name: Connection pool name
"""
if not hasattr(self, "connection_timeouts"):
return
attrs = self.attr_builder.build_connection_attributes(pool_name=pool_name)
self.connection_timeouts.add(1, attributes=attrs)
def record_connection_create_time(
self,
connection_pool: Union["ConnectionPoolInterface", "ConnectionPool"],
duration_seconds: float,
) -> None:
"""
Record time taken to create a new connection.
Args:
connection_pool: Connection pool implementation
duration_seconds: Creation time in seconds
"""
if not hasattr(self, "connection_create_time"):
return
attrs = self.attr_builder.build_connection_attributes(
pool_name=get_pool_name(connection_pool)
)
self.connection_create_time.record(duration_seconds, attributes=attrs)
def record_connection_wait_time(
self,
pool_name: str,
duration_seconds: float,
) -> None:
"""
Record time taken to obtain a connection from the pool.
Args:
pool_name: Connection pool name
duration_seconds: Wait time in seconds
"""
if not hasattr(self, "connection_wait_time"):
return
attrs = self.attr_builder.build_connection_attributes(pool_name=pool_name)
self.connection_wait_time.record(duration_seconds, attributes=attrs)
# Command execution metric recording methods
@deprecated_args(
args_to_warn=["batch_size"],
reason="The batch_size argument is no longer used and will be removed in the next major version.",
version="7.2.1",
)
def record_operation_duration(
self,
command_name: str,
duration_seconds: float,
server_address: Optional[str] = None,
server_port: Optional[int] = None,
db_namespace: Optional[int] = None,
batch_size: Optional[int] = None, # noqa
error_type: Optional[Exception] = None,
network_peer_address: Optional[str] = None,
network_peer_port: Optional[int] = None,
retry_attempts: Optional[int] = None,
is_blocking: Optional[bool] = None,
) -> None:
"""
Record command execution duration.
Args:
command_name: Redis command name (e.g., 'GET', 'SET', 'MULTI')
duration_seconds: Execution time in seconds
server_address: Redis server address
server_port: Redis server port
db_namespace: Redis database index
batch_size: Number of commands in batch (for pipelines/transactions)
error_type: Error type if operation failed
network_peer_address: Resolved peer address
network_peer_port: Peer port number
retry_attempts: Number of retry attempts made
is_blocking: Whether the operation is a blocking command
"""
if not hasattr(self, "operation_duration"):
return
# Check if this command should be tracked
if not self.config.should_track_command(command_name):
return
# Build attributes
attrs = self.attr_builder.build_base_attributes(
server_address=server_address,
server_port=server_port,
db_namespace=db_namespace,
)
attrs.update(
self.attr_builder.build_operation_attributes(
command_name=command_name,
network_peer_address=network_peer_address,
network_peer_port=network_peer_port,
retry_attempts=retry_attempts,
is_blocking=is_blocking,
)
)
attrs.update(
self.attr_builder.build_error_attributes(
error_type=error_type,
)
)
self.operation_duration.record(duration_seconds, attributes=attrs)
def record_connection_closed(
self,
close_reason: Optional[CloseReason] = None,
error_type: Optional[Exception] = None,
) -> None:
"""
Record a connection closed event.
Args:
close_reason: Reason for closing (e.g. 'error', 'application_close')
error_type: Error type if closed due to error
"""
if not hasattr(self, "connection_closed"):
return
attrs = self.attr_builder.build_connection_attributes()
if close_reason:
attrs[REDIS_CLIENT_CONNECTION_CLOSE_REASON] = close_reason.value
attrs.update(
self.attr_builder.build_error_attributes(
error_type=error_type,
)
)
self.connection_closed.add(1, attributes=attrs)
def record_connection_relaxed_timeout(
self,
connection_name: str,
maint_notification: str,
relaxed: bool,
) -> None:
"""
Record a connection timeout relaxation event.
Args:
connection_name: Connection name
maint_notification: Maintenance notification type
relaxed: True to count up (relaxed), False to count down (unrelaxed)
"""
if not hasattr(self, "connection_relaxed_timeout"):
return
attrs = self.attr_builder.build_connection_attributes(pool_name=connection_name)
attrs[REDIS_CLIENT_CONNECTION_NOTIFICATION] = maint_notification
self.connection_relaxed_timeout.add(1 if relaxed else -1, attributes=attrs)
def record_connection_handoff(
self,
pool_name: str,
) -> None:
"""
Record a connection handoff event (e.g., after MOVING notification).
Args:
pool_name: Connection pool name
"""
if not hasattr(self, "connection_handoff"):
return
attrs = self.attr_builder.build_connection_attributes(pool_name=pool_name)
self.connection_handoff.add(1, attributes=attrs)
# PubSub metric recording methods
def record_pubsub_message(
self,
direction: PubSubDirection,
channel: Optional[str] = None,
sharded: Optional[bool] = None,
) -> None:
"""
Record a PubSub message (published or received).
Args:
direction: Message direction ('publish' or 'receive')
channel: Pub/Sub channel name
sharded: True if sharded Pub/Sub channel
"""
if not hasattr(self, "pubsub_messages"):
return
attrs = self.attr_builder.build_pubsub_message_attributes(
direction=direction,
channel=channel,
sharded=sharded,
)
self.pubsub_messages.add(1, attributes=attrs)
# Streaming metric recording methods
@deprecated_args(
args_to_warn=["consumer_name"],
reason="The consumer_name argument is no longer used and will be removed in the next major version.",
version="7.2.1",
)
def record_streaming_lag(
self,
lag_seconds: float,
stream_name: Optional[str] = None,
consumer_group: Optional[str] = None,
consumer_name: Optional[str] = None, # noqa
) -> None:
"""
Record the lag of a streaming message.
Args:
lag_seconds: Lag in seconds
stream_name: Stream name
consumer_group: Consumer group name
consumer_name: Consumer name
"""
if not hasattr(self, "stream_lag"):
return
attrs = self.attr_builder.build_streaming_attributes(
stream_name=stream_name,
consumer_group=consumer_group,
)
self.stream_lag.record(lag_seconds, attributes=attrs)
# CSC metric recording methods
def record_csc_request(
self,
result: Optional[CSCResult] = None,
) -> None:
"""
Record a Client Side Caching (CSC) request.
Args:
result: CSC result ('hit' or 'miss')
"""
if not hasattr(self, "csc_requests"):
return
attrs = self.attr_builder.build_csc_attributes(result=result)
self.csc_requests.add(1, attributes=attrs)
def record_csc_eviction(
self,
count: int,
reason: Optional[CSCReason] = None,
) -> None:
"""
Record a Client Side Caching (CSC) eviction.
Args:
count: Number of evictions
reason: Reason for eviction
"""
if not hasattr(self, "csc_evictions"):
return
attrs = self.attr_builder.build_csc_attributes(reason=reason)
self.csc_evictions.add(count, attributes=attrs)
def record_csc_network_saved(
self,
bytes_saved: int,
) -> None:
"""
Record the number of bytes saved by using Client Side Caching (CSC).
Args:
bytes_saved: Number of bytes saved
"""
if not hasattr(self, "csc_network_saved"):
return
attrs = self.attr_builder.build_csc_attributes()
self.csc_network_saved.add(bytes_saved, attributes=attrs)
# Utility methods
@staticmethod
def monotonic_time() -> float:
"""
Get monotonic time for duration measurements.
Returns:
Current monotonic time in seconds
"""
return time.monotonic()
def __repr__(self) -> str:
return f"RedisMetricsCollector(meter={self.meter}, config={self.config})"

View file

@ -0,0 +1,371 @@
"""
OpenTelemetry provider management for redis-py.
This module handles initialization and lifecycle management of OTel SDK components
including MeterProvider, TracerProvider (future), and LoggerProvider (future).
Uses a singleton pattern - initialize once globally, all Redis clients use it automatically.
Redis-py uses the global MeterProvider set by your application. Set it up before
initializing observability:
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
provider = MeterProvider(...)
metrics.set_meter_provider(provider)
# Then initialize redis-py observability
otel = get_observability_instance()
otel.init(OTelConfig(enable_metrics=True))
"""
import logging
from typing import Optional
from redis.observability.config import OTelConfig
logger = logging.getLogger(__name__)
# Optional imports - OTel SDK may not be installed
try:
from opentelemetry.sdk.metrics import MeterProvider
OTEL_AVAILABLE = True
except ImportError:
OTEL_AVAILABLE = False
MeterProvider = None
# Global singleton instance
_global_provider_manager: Optional["OTelProviderManager"] = None
class OTelProviderManager:
"""
Manages OpenTelemetry SDK providers and their lifecycle.
This class handles:
- Getting the global MeterProvider set by the application
- Configuring histogram bucket boundaries via Views
- Graceful shutdown
Args:
config: OTel configuration object
"""
def __init__(self, config: OTelConfig):
self.config = config
self._meter_provider: Optional[MeterProvider] = None
def get_meter_provider(self) -> Optional[MeterProvider]:
"""
Get the global MeterProvider set by the application.
Returns:
MeterProvider instance or None if metrics are disabled
Raises:
ImportError: If OpenTelemetry is not installed
RuntimeError: If metrics are enabled but no global MeterProvider is set
"""
if not self.config.is_enabled():
return None
# Lazy import - only import OTel when metrics are enabled
try:
from opentelemetry import metrics
from opentelemetry.metrics import NoOpMeterProvider
except ImportError:
raise ImportError(
"OpenTelemetry is not installed. Install it with:\n"
" pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http"
)
# Get the global MeterProvider
if self._meter_provider is None:
self._meter_provider = metrics.get_meter_provider()
# Check if it's a real provider (not NoOp)
if isinstance(self._meter_provider, NoOpMeterProvider):
raise RuntimeError(
"Metrics are enabled but no global MeterProvider is configured.\n"
"\n"
"Set up OpenTelemetry before initializing redis-py observability:\n"
"\n"
" from opentelemetry import metrics\n"
" from opentelemetry.sdk.metrics import MeterProvider\n"
" from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader\n"
" from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter\n"
"\n"
" # Create exporter\n"
" exporter = OTLPMetricExporter(\n"
" endpoint='http://localhost:4318/v1/metrics'\n"
" )\n"
"\n"
" # Create reader\n"
" reader = PeriodicExportingMetricReader(\n"
" exporter=exporter,\n"
" export_interval_millis=10000\n"
" )\n"
"\n"
" # Create and set global provider\n"
" provider = MeterProvider(metric_readers=[reader])\n"
" metrics.set_meter_provider(provider)\n"
"\n"
" # Now initialize redis-py observability\n"
" from redis.observability import get_observability_instance, OTelConfig\n"
" otel = get_observability_instance()\n"
" otel.init(OTelConfig(enable_metrics=True))\n"
)
logger.info("Using global MeterProvider from application")
return self._meter_provider
def shutdown(self, timeout_millis: int = 30000) -> bool:
"""
Shutdown observability and flush any pending metrics.
Note: We don't shutdown the global MeterProvider since it's owned by the application.
We only force flush pending metrics.
Args:
timeout_millis: Maximum time to wait for flush
Returns:
True if flush was successful, False otherwise
"""
logger.debug(
"Flushing metrics before shutdown (not shutting down global MeterProvider)"
)
return self.force_flush(timeout_millis=timeout_millis)
def force_flush(self, timeout_millis: int = 30000) -> bool:
"""
Force flush any pending metrics from the global MeterProvider.
Args:
timeout_millis: Maximum time to wait for flush
Returns:
True if flush was successful, False otherwise
"""
if self._meter_provider is None:
return True
# NoOpMeterProvider doesn't have force_flush method
if not hasattr(self._meter_provider, "force_flush"):
logger.debug("MeterProvider does not support force_flush, skipping")
return True
try:
logger.debug("Force flushing metrics from global MeterProvider")
self._meter_provider.force_flush(timeout_millis=timeout_millis)
return True
except Exception as e:
logger.error(f"Error flushing metrics: {e}")
return False
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, _exc_type, _exc_val, _exc_tb):
"""Context manager exit - shutdown provider."""
self.shutdown()
def __repr__(self) -> str:
return f"OTelProviderManager(config={self.config})"
# Singleton instance class
class ObservabilityInstance:
"""
Singleton instance for managing OpenTelemetry observability.
This class follows the singleton pattern similar to Glide's GetOtelInstance().
Use GetObservabilityInstance() to get the singleton instance, then call init()
to initialize observability.
Example:
>>> from redis.observability.config import OTelConfig
>>>
>>> # Get singleton instance
>>> otel = get_observability_instance()
>>>
>>> # Initialize once at app startup
>>> otel.init(OTelConfig())
>>>
>>> # All Redis clients now automatically collect metrics
>>> import redis
>>> r = redis.Redis(host='localhost', port=6379)
>>> r.set('key', 'value') # Metrics collected automatically
"""
def __init__(self):
self._provider_manager: Optional[OTelProviderManager] = None
def init(self, config: OTelConfig) -> "ObservabilityInstance":
"""
Initialize OpenTelemetry observability globally for all Redis clients.
This should be called once at application startup. After initialization,
all Redis clients will automatically collect and export metrics without
needing any additional configuration.
Safe to call multiple times - will shutdown previous instance before
initializing a new one.
Args:
config: OTel configuration object
Returns:
Self for method chaining
Example:
>>> otel = get_observability_instance()
>>> otel.init(OTelConfig())
"""
if self._provider_manager is not None:
logger.warning(
"Observability already initialized. Shutting down previous instance."
)
self._provider_manager.shutdown()
self._provider_manager = OTelProviderManager(config)
logger.info("Observability initialized")
return self
def is_enabled(self) -> bool:
"""
Check if observability is enabled.
Returns:
True if observability is initialized and metrics are enabled
Example:
>>> otel = get_observability_instance()
>>> if otel.is_enabled():
... print("Metrics are being collected")
"""
return (
self._provider_manager is not None
and self._provider_manager.config.is_enabled()
)
def get_provider_manager(self) -> Optional[OTelProviderManager]:
"""
Get the provider manager instance.
Returns:
The provider manager, or None if not initialized
Example:
>>> otel = get_observability_instance()
>>> manager = otel.get_provider_manager()
>>> if manager is not None:
... print(f"Observability enabled: {manager.config.is_enabled()}")
"""
return self._provider_manager
def shutdown(self, timeout_millis: int = 30000) -> bool:
"""
Shutdown observability and flush any pending metrics.
This should be called at application shutdown to ensure all metrics
are exported before the application exits.
Args:
timeout_millis: Maximum time to wait for shutdown
Returns:
True if shutdown was successful
Example:
>>> otel = get_observability_instance()
>>> # At application shutdown
>>> otel.shutdown()
"""
if self._provider_manager is None:
logger.debug("Observability not initialized, nothing to shutdown")
return True
success = self._provider_manager.shutdown(timeout_millis)
self._provider_manager = None
logger.info("Observability shutdown")
return success
def force_flush(self, timeout_millis: int = 30000) -> bool:
"""
Force flush all pending metrics immediately.
Useful for testing or when you want to ensure metrics are exported
before a specific point in your application.
Args:
timeout_millis: Maximum time to wait for flush
Returns:
True if flush was successful
Example:
>>> otel = get_observability_instance()
>>> # Execute some Redis commands
>>> r.set('key', 'value')
>>> # Force flush metrics immediately
>>> otel.force_flush()
"""
if self._provider_manager is None:
logger.debug("Observability not initialized, nothing to flush")
return True
return self._provider_manager.force_flush(timeout_millis)
# Global singleton instance
_observability_instance: Optional[ObservabilityInstance] = None
def get_observability_instance() -> ObservabilityInstance:
"""
Get the global observability singleton instance.
This is the Pythonic way to get the singleton instance.
Returns:
The global ObservabilityInstance singleton
Example:
>>>
>>> otel = get_observability_instance()
>>> otel.init(OTelConfig())
"""
global _observability_instance
if _observability_instance is None:
_observability_instance = ObservabilityInstance()
return _observability_instance
def reset_observability_instance() -> None:
"""
Reset the global observability singleton instance.
This is primarily used for testing and benchmarking to ensure
a clean state between test runs.
Warning:
This will shutdown any active provider manager and reset
the global state. Use with caution in production code.
"""
global _observability_instance
if _observability_instance is not None:
_observability_instance.shutdown()
_observability_instance = None

View file

@ -0,0 +1,888 @@
"""
Simple, clean API for recording observability metrics.
This module provides a straightforward interface for Redis core code to record
metrics without needing to know about OpenTelemetry internals.
Usage in Redis core code:
from redis.observability.recorder import record_operation_duration
start_time = time.monotonic()
# ... execute Redis command ...
record_operation_duration(
command_name='SET',
duration_seconds=time.monotonic() - start_time,
server_address='localhost',
server_port=6379,
db_namespace='0',
error=None
)
"""
from datetime import datetime
from typing import TYPE_CHECKING, Callable, List, Optional
from redis.observability.attributes import (
AttributeBuilder,
ConnectionState,
CSCReason,
CSCResult,
GeoFailoverReason,
PubSubDirection,
)
from redis.observability.metrics import CloseReason, RedisMetricsCollector
from redis.observability.providers import get_observability_instance
from redis.observability.registry import get_observables_registry_instance
from redis.utils import deprecated_args, deprecated_function, str_if_bytes
if TYPE_CHECKING:
from redis.connection import ConnectionPoolInterface
from redis.multidb.database import SyncDatabase
from redis.observability.config import OTelConfig
# Global metrics collector instance (lazy-initialized)
_metrics_collector: Optional[RedisMetricsCollector] = None
CSC_ITEMS_REGISTRY_KEY = "csc_items"
CONNECTION_COUNT_REGISTRY_KEY = "connection_count"
@deprecated_args(
args_to_warn=["batch_size"],
reason="The batch_size argument is no longer used and will be removed in the next major version.",
version="7.2.1",
)
def record_operation_duration(
command_name: str,
duration_seconds: float,
server_address: Optional[str] = None,
server_port: Optional[int] = None,
db_namespace: Optional[str] = None,
error: Optional[Exception] = None,
is_blocking: Optional[bool] = None,
batch_size: Optional[int] = None, # noqa
retry_attempts: Optional[int] = None,
) -> None:
"""
Record a Redis command execution duration.
This is a simple, clean API that Redis core code can call directly.
If observability is not enabled, this returns immediately with zero overhead.
Args:
command_name: Redis command name (e.g., 'GET', 'SET')
duration_seconds: Command execution time in seconds
server_address: Redis server address
server_port: Redis server port
db_namespace: Redis database index
error: Exception if command failed, None if successful
is_blocking: Whether the operation is a blocking command
batch_size: Number of commands in batch (for pipelines/transactions)
retry_attempts: Number of retry attempts made
Example:
>>> start = time.monotonic()
>>> # ... execute command ...
>>> record_operation_duration('SET', time.monotonic() - start, 'localhost', 6379, '0')
"""
global _metrics_collector
# Fast path: if collector not initialized, observability is disabled
if _metrics_collector is None:
# Try to initialize (only once)
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return # Observability not enabled
# Record the metric
try:
_metrics_collector.record_operation_duration(
command_name=command_name,
duration_seconds=duration_seconds,
server_address=server_address,
server_port=server_port,
db_namespace=db_namespace,
error_type=error,
network_peer_address=server_address,
network_peer_port=server_port,
is_blocking=is_blocking,
retry_attempts=retry_attempts,
)
except Exception:
# Don't let metric recording errors break Redis operations
pass
def record_connection_create_time(
connection_pool: "ConnectionPoolInterface",
duration_seconds: float,
) -> None:
"""
Record connection creation time.
Args:
connection_pool: Connection pool implementation
duration_seconds: Time taken to create connection in seconds
Example:
>>> start = time.monotonic()
>>> # ... create connection ...
>>> record_connection_create_time('ConnectionPool<localhost:6379>', time.monotonic() - start)
"""
global _metrics_collector
# Fast path: if collector not initialized, observability is disabled
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_connection_create_time(
connection_pool=connection_pool,
duration_seconds=duration_seconds,
)
except Exception:
pass
def record_connection_count(
pool_name: str,
connection_state: ConnectionState,
counter: int = 1,
) -> None:
"""
Record a connection count change for a single state.
Args:
pool_name: Connection pool identifier
connection_state: State to update (IDLE or USED)
counter: Number to add (positive) or subtract (negative)
Example:
# New connection created (goes to IDLE first)
>>> record_connection_count('pool_abc123', ConnectionState.IDLE, 1)
# Acquire from pool (transition)
>>> record_connection_count('pool_abc123', ConnectionState.IDLE, -1)
>>> record_connection_count('pool_abc123', ConnectionState.USED, 1)
# Release to pool (transition)
>>> record_connection_count('pool_abc123', ConnectionState.USED, -1)
>>> record_connection_count('pool_abc123', ConnectionState.IDLE, 1)
# Pool disconnect 5 idle connections
>>> record_connection_count('pool_abc123', ConnectionState.IDLE, -5)
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_connection_count(
pool_name=pool_name,
connection_state=connection_state,
counter=counter,
)
except Exception:
pass
@deprecated_function(
reason="Connection count is now tracked via record_connection_count(). "
"This functionality will be removed in the next major version",
version="7.4.0",
)
def init_connection_count() -> None:
"""
Initialize observable gauge for connection count metric.
"""
collector = _get_or_create_collector()
if collector is None:
return
def observable_callback(__):
observables_registry = get_observables_registry_instance()
callbacks = observables_registry.get(CONNECTION_COUNT_REGISTRY_KEY)
observations = []
for callback in callbacks:
observations.extend(callback())
return observations
try:
collector.init_connection_count(
callback=observable_callback,
)
except Exception:
pass
@deprecated_function(
reason="Connection count is now tracked via record_connection_count(). "
"This functionality will be removed in the next major version",
version="7.4.0",
)
def register_pools_connection_count(
connection_pools: List["ConnectionPoolInterface"],
) -> None:
"""
Add connection pools to connection count observable registry.
"""
collector = _get_or_create_collector()
if collector is None:
return
try:
# Lazy import
from opentelemetry.metrics import Observation
def connection_count_callback():
observations = []
for connection_pool in connection_pools:
for count, attributes in connection_pool.get_connection_count():
observations.append(Observation(count, attributes=attributes))
return observations
observables_registry = get_observables_registry_instance()
observables_registry.register(
CONNECTION_COUNT_REGISTRY_KEY, connection_count_callback
)
except Exception:
pass
def record_connection_timeout(
pool_name: str,
) -> None:
"""
Record a connection timeout event.
Args:
pool_name: Connection pool identifier
Example:
>>> record_connection_timeout('ConnectionPool<localhost:6379>')
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_connection_timeout(
pool_name=pool_name,
)
except Exception:
pass
def record_connection_wait_time(
pool_name: str,
duration_seconds: float,
) -> None:
"""
Record time taken to obtain a connection from the pool.
Args:
pool_name: Connection pool identifier
duration_seconds: Wait time in seconds
Example:
>>> start = time.monotonic()
>>> # ... wait for connection from pool ...
>>> record_connection_wait_time('ConnectionPool<localhost:6379>', time.monotonic() - start)
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_connection_wait_time(
pool_name=pool_name,
duration_seconds=duration_seconds,
)
except Exception:
pass
def record_connection_closed(
close_reason: Optional[CloseReason] = None,
error_type: Optional[Exception] = None,
) -> None:
"""
Record a connection closed event.
Args:
close_reason: Reason for closing (e.g. 'error', 'application_close')
error_type: Error type if closed due to error
Example:
>>> record_connection_closed('ConnectionPool<localhost:6379>', 'idle_timeout')
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_connection_closed(
close_reason=close_reason,
error_type=error_type,
)
except Exception:
pass
def record_connection_relaxed_timeout(
connection_name: str,
maint_notification: str,
relaxed: bool,
) -> None:
"""
Record a connection timeout relaxation event.
Args:
connection_name: Connection identifier
maint_notification: Maintenance notification type
relaxed: True to count up (relaxed), False to count down (unrelaxed)
Example:
>>> record_connection_relaxed_timeout('localhost:6379_a1b2c3d4', 'MOVING', True)
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_connection_relaxed_timeout(
connection_name=connection_name,
maint_notification=maint_notification,
relaxed=relaxed,
)
except Exception:
pass
def record_connection_handoff(
pool_name: str,
) -> None:
"""
Record a connection handoff event (e.g., after MOVING notification).
Args:
pool_name: Connection pool identifier
Example:
>>> record_connection_handoff('ConnectionPool<localhost:6379>')
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_connection_handoff(
pool_name=pool_name,
)
except Exception:
pass
def record_error_count(
server_address: Optional[str] = None,
server_port: Optional[int] = None,
network_peer_address: Optional[str] = None,
network_peer_port: Optional[int] = None,
error_type: Optional[Exception] = None,
retry_attempts: Optional[int] = None,
is_internal: bool = True,
) -> None:
"""
Record error count.
Args:
server_address: Server address
server_port: Server port
network_peer_address: Network peer address
network_peer_port: Network peer port
error_type: Error type (Exception)
retry_attempts: Retry attempts
is_internal: Whether the error is internal (e.g., timeout, network error)
Example:
>>> record_error_count('localhost', 6379, 'localhost', 6379, ConnectionError(), 3)
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_error_count(
server_address=server_address,
server_port=server_port,
network_peer_address=network_peer_address,
network_peer_port=network_peer_port,
error_type=error_type,
retry_attempts=retry_attempts,
is_internal=is_internal,
)
except Exception:
pass
def record_pubsub_message(
direction: PubSubDirection,
channel: Optional[str] = None,
sharded: Optional[bool] = None,
) -> None:
"""
Record a PubSub message (published or received).
Args:
direction: Message direction ('publish' or 'receive')
channel: Pub/Sub channel name
sharded: True if sharded Pub/Sub channel
Example:
>>> record_pubsub_message(PubSubDirection.PUBLISH, 'channel', False)
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
# Check if channel names should be hidden
effective_channel = channel
if channel is not None:
config = _get_config()
if config is not None and config.hide_pubsub_channel_names:
effective_channel = None
try:
_metrics_collector.record_pubsub_message(
direction=direction,
channel=effective_channel,
sharded=sharded,
)
except Exception:
pass
@deprecated_args(
args_to_warn=["consumer_name"],
reason="The consumer_name argument is no longer used and will be removed in the next major version.",
version="7.2.1",
)
def record_streaming_lag(
lag_seconds: float,
stream_name: Optional[str] = None,
consumer_group: Optional[str] = None,
consumer_name: Optional[str] = None, # noqa
) -> None:
"""
Record the lag of a streaming message.
Args:
lag_seconds: Lag in seconds
stream_name: Stream name
consumer_group: Consumer group name
consumer_name: Consumer name
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
# Check if stream names should be hidden
effective_stream_name = stream_name
if stream_name is not None:
config = _get_config()
if config is not None and config.hide_stream_names:
effective_stream_name = None
try:
_metrics_collector.record_streaming_lag(
lag_seconds=lag_seconds,
stream_name=effective_stream_name,
consumer_group=consumer_group,
)
except Exception:
pass
@deprecated_args(
args_to_warn=["consumer_name"],
reason="The consumer_name argument is no longer used and will be removed in the next major version.",
version="7.2.1",
)
def record_streaming_lag_from_response(
response,
consumer_group: Optional[str] = None,
consumer_name: Optional[str] = None, # noqa
) -> None:
"""
Record streaming lag from XREAD/XREADGROUP response.
Parses the response and calculates lag for each message based on message ID timestamp.
Args:
response: Response from XREAD/XREADGROUP command
consumer_group: Consumer group name (for XREADGROUP)
consumer_name: Consumer name (for XREADGROUP)
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
if not response:
return
try:
now = datetime.now().timestamp()
# Check if stream names should be hidden
config = _get_config()
hide_stream_names = config is not None and config.hide_stream_names
# RESP3 format: dict
if isinstance(response, dict):
for stream_name, stream_messages in response.items():
effective_stream_name = (
None if hide_stream_names else str_if_bytes(stream_name)
)
for messages in stream_messages:
for message in messages:
message_id, _ = message
message_id = str_if_bytes(message_id)
timestamp, _ = message_id.split("-")
# Ensure lag is non-negative (clock skew can cause negative values)
lag_seconds = max(0.0, now - int(timestamp) / 1000)
_metrics_collector.record_streaming_lag(
lag_seconds=lag_seconds,
stream_name=effective_stream_name,
consumer_group=consumer_group,
)
else:
# RESP2 format: list
for stream_entry in response:
stream_name = str_if_bytes(stream_entry[0])
effective_stream_name = None if hide_stream_names else stream_name
for message in stream_entry[1]:
message_id, _ = message
message_id = str_if_bytes(message_id)
timestamp, _ = message_id.split("-")
# Ensure lag is non-negative (clock skew can cause negative values)
lag_seconds = max(0.0, now - int(timestamp) / 1000)
_metrics_collector.record_streaming_lag(
lag_seconds=lag_seconds,
stream_name=effective_stream_name,
consumer_group=consumer_group,
)
except Exception:
pass
def record_maint_notification_count(
server_address: str,
server_port: int,
network_peer_address: str,
network_peer_port: int,
maint_notification: str,
) -> None:
"""
Record a maintenance notification count.
Args:
server_address: Server address
server_port: Server port
network_peer_address: Network peer address
network_peer_port: Network peer port
maint_notification: Maintenance notification type (e.g., 'MOVING', 'MIGRATING')
Example:
>>> record_maint_notification_count('localhost', 6379, 'localhost', 6379, 'MOVING')
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_maint_notification_count(
server_address=server_address,
server_port=server_port,
network_peer_address=network_peer_address,
network_peer_port=network_peer_port,
maint_notification=maint_notification,
)
except Exception:
pass
def record_csc_request(
result: Optional[CSCResult] = None,
):
"""
Record a Client Side Caching (CSC) request.
Args:
result: CSC result ('hit' or 'miss')
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_csc_request(
result=result,
)
except Exception:
pass
def init_csc_items() -> None:
"""
Initialize observable gauge for CSC items metric.
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
def observable_callback(__):
observables_registry = get_observables_registry_instance()
callbacks = observables_registry.get(CSC_ITEMS_REGISTRY_KEY)
observations = []
for callback in callbacks:
observations.extend(callback())
return observations
try:
_metrics_collector.init_csc_items(
callback=observable_callback,
)
except Exception:
pass
def register_csc_items_callback(
callback: Callable,
pool_name: Optional[str] = None,
) -> None:
"""
Adds given callback to CSC items observable registry.
Args:
callback: Callback function that returns the cache size
pool_name: Connection pool name for observability
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
# Lazy import
from opentelemetry.metrics import Observation
def csc_items_callback():
return [
Observation(
callback(),
attributes=AttributeBuilder.build_csc_attributes(pool_name=pool_name),
)
]
try:
observables_registry = get_observables_registry_instance()
observables_registry.register(CSC_ITEMS_REGISTRY_KEY, csc_items_callback)
except Exception:
pass
def record_csc_eviction(
count: int,
reason: Optional[CSCReason] = None,
) -> None:
"""
Record a Client Side Caching (CSC) eviction.
Args:
count: Number of evictions
reason: Reason for eviction
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_csc_eviction(
count=count,
reason=reason,
)
except Exception:
pass
def record_csc_network_saved(
bytes_saved: int,
) -> None:
"""
Record the number of bytes saved by using Client Side Caching (CSC).
Args:
bytes_saved: Number of bytes saved
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_csc_network_saved(
bytes_saved=bytes_saved,
)
except Exception:
pass
def record_geo_failover(
fail_from: "SyncDatabase",
fail_to: "SyncDatabase",
reason: GeoFailoverReason,
) -> None:
"""
Record a geo failover.
Args:
fail_from: Database failed from
fail_to: Database failed to
reason: Reason for the failover
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
if _metrics_collector is None:
return
try:
_metrics_collector.record_geo_failover(
fail_from=fail_from,
fail_to=fail_to,
reason=reason,
)
except Exception:
pass
def _get_or_create_collector() -> Optional[RedisMetricsCollector]:
"""
Get or create the global metrics collector.
Returns:
RedisMetricsCollector instance if observability is enabled, None otherwise
"""
try:
manager = get_observability_instance().get_provider_manager()
if manager is None or not manager.config.enabled_telemetry:
return None
# Get meter from the global MeterProvider
meter = manager.get_meter_provider().get_meter(
RedisMetricsCollector.METER_NAME, RedisMetricsCollector.METER_VERSION
)
return RedisMetricsCollector(meter, manager.config)
except ImportError:
# Observability module not available
return None
except Exception:
# Any other error - don't break Redis operations
return None
def _get_config() -> Optional["OTelConfig"]:
"""
Get the OTel configuration from the observability manager.
Returns:
OTelConfig instance if observability is enabled, None otherwise
"""
try:
manager = get_observability_instance().get_provider_manager()
if manager is None:
return None
return manager.config
except Exception:
return None
def reset_collector() -> None:
"""
Reset the global collector (used for testing or re-initialization).
"""
global _metrics_collector
_metrics_collector = None
def is_enabled() -> bool:
"""
Check if observability is enabled.
Returns:
True if metrics are being collected, False otherwise
"""
global _metrics_collector
if _metrics_collector is None:
_metrics_collector = _get_or_create_collector()
return _metrics_collector is not None

View file

@ -0,0 +1,75 @@
import threading
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
# Optional import - OTel SDK may not be installed
# Use Any as fallback type when OTel is not available
if TYPE_CHECKING:
try:
from opentelemetry.metrics import Observation
except ImportError:
Observation = Any # type: ignore[misc]
else:
Observation = Any
class ObservablesRegistry:
"""
Global registry for storing callbacks for observable metrics.
"""
def __init__(self, registry: Dict[str, List[Callable[[], List[Any]]]] = None):
self._registry = registry or {}
self._lock = threading.Lock()
def register(self, name: str, callback: Callable[[], List[Any]]) -> None:
"""
Register a callback for an observable metric.
"""
with self._lock:
self._registry.setdefault(name, []).append(callback)
def get(self, name: str) -> List[Callable[[], List[Any]]]:
"""
Get all callbacks for an observable metric.
"""
with self._lock:
return self._registry.get(name, [])
def clear(self) -> None:
"""
Clear the registry.
"""
with self._lock:
self._registry.clear()
def __len__(self) -> int:
"""
Get the number of registered callbacks.
"""
return len(self._registry)
# Global singleton instance
_observables_registry_instance: Optional[ObservablesRegistry] = None
def get_observables_registry_instance() -> ObservablesRegistry:
"""
Get the global observables registry singleton instance.
This is the Pythonic way to get the singleton instance.
Returns:
The global ObservablesRegistry singleton
Example:
>>>
>>> registry = get_observables_registry_instance()
>>> registry.register('my_metric', my_callback)
"""
global _observables_registry_instance
if _observables_registry_instance is None:
_observables_registry_instance = ObservablesRegistry()
return _observables_registry_instance