Voice et bot modif
This commit is contained in:
parent
189d56026b
commit
7333a22bcd
10774 changed files with 634644 additions and 933308 deletions
|
|
@ -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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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}"
|
||||
181
venv/lib/python3.12/site-packages/redis/observability/config.py
Normal file
181
venv/lib/python3.12/site-packages/redis/observability/config.py
Normal 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}"
|
||||
727
venv/lib/python3.12/site-packages/redis/observability/metrics.py
Normal file
727
venv/lib/python3.12/site-packages/redis/observability/metrics.py
Normal 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})"
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue