add
This commit is contained in:
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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})"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user