python-etcd3-async 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- etcd3/__init__.py +90 -0
- etcd3/async_client.py +1495 -0
- etcd3/async_client_extensions.py +878 -0
- etcd3/async_endpoint.py +140 -0
- etcd3/async_managers.py +498 -0
- etcd3/async_monitor.py +16 -0
- etcd3/async_watch.py +462 -0
- etcd3/client.py +2021 -0
- etcd3/endpoint.py +79 -0
- etcd3/events.py +40 -0
- etcd3/exceptions.py +47 -0
- etcd3/exporters/__init__.py +18 -0
- etcd3/exporters/prometheus.py +365 -0
- etcd3/leases.py +106 -0
- etcd3/locks.py +593 -0
- etcd3/log.py +146 -0
- etcd3/log_formatters.py +228 -0
- etcd3/log_sampling.py +221 -0
- etcd3/members.py +44 -0
- etcd3/observability.py +431 -0
- etcd3/shared.py +486 -0
- etcd3/trace.py +231 -0
- etcd3/trace_attributes.py +434 -0
- etcd3/transactions.py +280 -0
- etcd3/utils.py +158 -0
- etcd3/watch.py +270 -0
- etcdrpc/__init__.py +16 -0
- etcdrpc/auth.proto +49 -0
- etcdrpc/auth_pb2.py +43 -0
- etcdrpc/auth_pb2.pyi +44 -0
- etcdrpc/auth_pb2_grpc.py +24 -0
- etcdrpc/kv.proto +43 -0
- etcdrpc/kv_pb2.py +40 -0
- etcdrpc/kv_pb2.pyi +39 -0
- etcdrpc/kv_pb2_grpc.py +24 -0
- etcdrpc/rpc.proto +924 -0
- etcdrpc/rpc_pb2.py +237 -0
- etcdrpc/rpc_pb2.pyi +811 -0
- etcdrpc/rpc_pb2_grpc.py +1893 -0
- python_etcd3_async-0.1.0.dist-info/METADATA +328 -0
- python_etcd3_async-0.1.0.dist-info/RECORD +42 -0
- python_etcd3_async-0.1.0.dist-info/WHEEL +4 -0
etcd3/log.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Logging utilities for etcd3 client.
|
|
2
|
+
|
|
3
|
+
This module provides logging functionality for etcd3 operations,
|
|
4
|
+
including decorators for logging key operations and trace context integration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from etcd3.trace import get_current_span, is_tracing_available
|
|
14
|
+
|
|
15
|
+
ENV_PREFIX = "ETCD_OBS_"
|
|
16
|
+
ENV_LOG_LEVEL = f"{ENV_PREFIX}LOG_LEVEL"
|
|
17
|
+
|
|
18
|
+
_log_operation: ContextVar[Optional[str]] = ContextVar("log_operation", default=None)
|
|
19
|
+
|
|
20
|
+
_log = logging.getLogger("etcd3")
|
|
21
|
+
_log.addHandler(logging.NullHandler())
|
|
22
|
+
|
|
23
|
+
_env_level = os.environ.get(ENV_LOG_LEVEL)
|
|
24
|
+
if _env_level:
|
|
25
|
+
try:
|
|
26
|
+
_log.setLevel(getattr(logging, _env_level.upper(), logging.DEBUG))
|
|
27
|
+
except (AttributeError, ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log_operation(operation_name: str):
|
|
32
|
+
"""Decorator to log operation start and completion for sync functions.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
operation_name: Name of the operation for logging
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Decorator function
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def decorator(func):
|
|
42
|
+
@functools.wraps(func)
|
|
43
|
+
def wrapper(*args, **kwargs):
|
|
44
|
+
_log_operation.set(operation_name)
|
|
45
|
+
if _log.isEnabledFor(logging.DEBUG):
|
|
46
|
+
_log.debug("[etcd3] Starting operation: %s", operation_name)
|
|
47
|
+
try:
|
|
48
|
+
result = func(*args, **kwargs)
|
|
49
|
+
if _log.isEnabledFor(logging.DEBUG):
|
|
50
|
+
_log.debug("[etcd3] Completed operation: %s", operation_name)
|
|
51
|
+
return result
|
|
52
|
+
except Exception as e:
|
|
53
|
+
_log.error("[etcd3] Operation failed: %s, error: %s", operation_name, e)
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
return wrapper
|
|
57
|
+
|
|
58
|
+
return decorator
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def log_operation_async(operation_name: str):
|
|
62
|
+
"""Decorator to log operation start and completion for async functions.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
operation_name: Name of the operation for logging
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Decorator function
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def decorator(func):
|
|
72
|
+
@functools.wraps(func)
|
|
73
|
+
async def wrapper(*args, **kwargs):
|
|
74
|
+
_log_operation.set(operation_name)
|
|
75
|
+
if _log.isEnabledFor(logging.DEBUG):
|
|
76
|
+
_log.debug("[etcd3] Starting operation: %s", operation_name)
|
|
77
|
+
try:
|
|
78
|
+
result = await func(*args, **kwargs)
|
|
79
|
+
if _log.isEnabledFor(logging.DEBUG):
|
|
80
|
+
_log.debug("[etcd3] Completed operation: %s", operation_name)
|
|
81
|
+
return result
|
|
82
|
+
except Exception as e:
|
|
83
|
+
_log.error("[etcd3] Operation failed: %s, error: %s", operation_name, e)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
return wrapper
|
|
87
|
+
|
|
88
|
+
return decorator
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_logger(operation: Optional[str] = None) -> logging.Logger:
|
|
92
|
+
"""Get the etcd3 logger instance, optionally with operation context.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
operation: Optional operation name to set in context
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Logger instance
|
|
99
|
+
"""
|
|
100
|
+
if operation:
|
|
101
|
+
_log_operation.set(operation)
|
|
102
|
+
return _log
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def set_log_level(level: str):
|
|
106
|
+
"""Set the log level for etcd3 logger.
|
|
107
|
+
|
|
108
|
+
This can also be configured via the ETCD3_LOG_LEVEL environment variable.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
level: Log level as string ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
|
|
112
|
+
"""
|
|
113
|
+
_log.setLevel(getattr(logging, level.upper(), logging.DEBUG))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_trace_context() -> tuple[Optional[str], Optional[str]]:
|
|
117
|
+
"""Get trace context (trace_id, span_id) from current span.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Tuple of (trace_id, span_id), or (None, None) if not available
|
|
121
|
+
"""
|
|
122
|
+
if not is_tracing_available():
|
|
123
|
+
return None, None
|
|
124
|
+
|
|
125
|
+
span = get_current_span()
|
|
126
|
+
if span is None:
|
|
127
|
+
return None, None
|
|
128
|
+
|
|
129
|
+
trace_id = span.get_span_context().trace_id
|
|
130
|
+
span_id = span.get_span_context().span_id
|
|
131
|
+
|
|
132
|
+
trace_id_hex = format(trace_id, "032x") if trace_id else None
|
|
133
|
+
span_id_hex = format(span_id, "016x") if span_id else None
|
|
134
|
+
|
|
135
|
+
return trace_id_hex, span_id_hex
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TraceContextLogRecord(logging.LogRecord):
|
|
139
|
+
"""LogRecord subclass that includes trace context."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, *args, **kwargs):
|
|
142
|
+
super().__init__(*args, **kwargs)
|
|
143
|
+
trace_id, span_id = get_trace_context()
|
|
144
|
+
self.trace_id = trace_id
|
|
145
|
+
self.span_id = span_id
|
|
146
|
+
self.operation = _log_operation.get()
|
etcd3/log_formatters.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Logging formatters for etcd3 client.
|
|
2
|
+
|
|
3
|
+
This module provides structured logging formatters including JSON and colored
|
|
4
|
+
console output, with support for trace context integration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JSONFormatter(logging.Formatter):
|
|
15
|
+
"""JSON structured log formatter.
|
|
16
|
+
|
|
17
|
+
Outputs logs as JSON objects with standardized fields for easy ingestion
|
|
18
|
+
into log aggregation systems like ELK, Loki, or cloud logging services.
|
|
19
|
+
|
|
20
|
+
Example output:
|
|
21
|
+
{
|
|
22
|
+
"timestamp": "2024-01-15T08:30:00.123456+00:00",
|
|
23
|
+
"level": "INFO",
|
|
24
|
+
"logger": "etcd3",
|
|
25
|
+
"message": "Completed operation: kv.put",
|
|
26
|
+
"operation": "kv.put",
|
|
27
|
+
"trace_id": "abcd1234...",
|
|
28
|
+
"span_id": "efgh5678...",
|
|
29
|
+
"source": {
|
|
30
|
+
"file": "etcd3/async_client_extensions.py",
|
|
31
|
+
"line": 177,
|
|
32
|
+
"function": "put"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, indent: Optional[int] = None):
|
|
38
|
+
"""Initialize JSON formatter.
|
|
39
|
+
|
|
40
|
+
:param indent: Indentation for pretty-printing (None for compact)
|
|
41
|
+
"""
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.indent = indent
|
|
44
|
+
|
|
45
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
46
|
+
"""Format log record as JSON.
|
|
47
|
+
|
|
48
|
+
:param record: Log record to format
|
|
49
|
+
:returns: JSON string
|
|
50
|
+
"""
|
|
51
|
+
# Build base log data
|
|
52
|
+
log_data: Dict[str, Any] = {
|
|
53
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
54
|
+
"level": record.levelname,
|
|
55
|
+
"logger": record.name,
|
|
56
|
+
"message": record.getMessage(),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Add extra fields if present
|
|
60
|
+
if hasattr(record, "operation") and record.operation:
|
|
61
|
+
log_data["operation"] = record.operation
|
|
62
|
+
|
|
63
|
+
if hasattr(record, "trace_id") and record.trace_id:
|
|
64
|
+
log_data["trace_id"] = record.trace_id
|
|
65
|
+
|
|
66
|
+
if hasattr(record, "span_id") and record.span_id:
|
|
67
|
+
log_data["span_id"] = record.span_id
|
|
68
|
+
|
|
69
|
+
if hasattr(record, "attributes") and record.attributes:
|
|
70
|
+
log_data["attributes"] = record.attributes
|
|
71
|
+
|
|
72
|
+
# Add exception info if present
|
|
73
|
+
if record.exc_info:
|
|
74
|
+
log_data["error"] = self.formatException(record.exc_info)
|
|
75
|
+
|
|
76
|
+
# Add source location
|
|
77
|
+
log_data["source"] = {
|
|
78
|
+
"file": record.pathname,
|
|
79
|
+
"line": record.lineno,
|
|
80
|
+
"function": record.funcName,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return json.dumps(log_data, indent=self.indent, default=str)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ColoredTextFormatter(logging.Formatter):
|
|
87
|
+
"""Colored text formatter for console output.
|
|
88
|
+
|
|
89
|
+
Adds ANSI color codes to log output for better readability in terminals.
|
|
90
|
+
Automatically detects if the output is a TTY and disables colors if not.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# ANSI color codes
|
|
94
|
+
COLORS = {
|
|
95
|
+
"DEBUG": "\033[36m", # Cyan
|
|
96
|
+
"INFO": "\033[32m", # Green
|
|
97
|
+
"WARNING": "\033[33m", # Yellow
|
|
98
|
+
"ERROR": "\033[31m", # Red
|
|
99
|
+
"CRITICAL": "\033[35m", # Magenta
|
|
100
|
+
"RESET": "\033[0m",
|
|
101
|
+
"BOLD": "\033[1m",
|
|
102
|
+
"DIM": "\033[2m",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def __init__(self, include_trace: bool = True, fmt: Optional[str] = None):
|
|
106
|
+
"""Initialize colored formatter.
|
|
107
|
+
|
|
108
|
+
:param include_trace: Whether to include trace context in output
|
|
109
|
+
:param fmt: Optional format string override
|
|
110
|
+
"""
|
|
111
|
+
super().__init__()
|
|
112
|
+
self.include_trace = include_trace
|
|
113
|
+
self.supports_color = sys.stderr.isatty()
|
|
114
|
+
self.fmt = (
|
|
115
|
+
fmt
|
|
116
|
+
or "%(asctime)s %(levelcolor)s[%(levelname)s]%(reset)s %(name)s: %(message)s"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _colorize(self, text: str, color: str) -> str:
|
|
120
|
+
"""Wrap text in color codes if supported.
|
|
121
|
+
|
|
122
|
+
:param text: Text to colorize
|
|
123
|
+
:param color: Color name
|
|
124
|
+
:returns: Colorized or plain text
|
|
125
|
+
"""
|
|
126
|
+
if not self.supports_color:
|
|
127
|
+
return text
|
|
128
|
+
reset = self.COLORS["RESET"]
|
|
129
|
+
color_code = self.COLORS.get(color, "")
|
|
130
|
+
return f"{color_code}{text}{reset}"
|
|
131
|
+
|
|
132
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
133
|
+
"""Format log record with colors.
|
|
134
|
+
|
|
135
|
+
:param record: Log record to format
|
|
136
|
+
:returns: Formatted string
|
|
137
|
+
"""
|
|
138
|
+
# Format timestamp
|
|
139
|
+
record.asctime = self.formatTime(record)
|
|
140
|
+
|
|
141
|
+
# Build main message
|
|
142
|
+
parts = [
|
|
143
|
+
record.asctime,
|
|
144
|
+
self._colorize(f"[{record.levelname}]", record.levelname),
|
|
145
|
+
record.name,
|
|
146
|
+
":",
|
|
147
|
+
record.getMessage(),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# Add trace context if enabled and available
|
|
151
|
+
if self.include_trace:
|
|
152
|
+
trace_parts = []
|
|
153
|
+
if hasattr(record, "operation") and record.operation:
|
|
154
|
+
trace_parts.append(f"op={record.operation}")
|
|
155
|
+
if hasattr(record, "trace_id") and record.trace_id:
|
|
156
|
+
short_trace = record.trace_id[:16]
|
|
157
|
+
trace_parts.append(f"trace={short_trace}...")
|
|
158
|
+
if hasattr(record, "span_id") and record.span_id:
|
|
159
|
+
trace_parts.append(f"span={record.span_id[:8]}")
|
|
160
|
+
|
|
161
|
+
if trace_parts:
|
|
162
|
+
parts.append(self._colorize(f"({', '.join(trace_parts)})", "DIM"))
|
|
163
|
+
|
|
164
|
+
# Add exception
|
|
165
|
+
result = " ".join(parts)
|
|
166
|
+
if record.exc_info:
|
|
167
|
+
result += "\n" + self.formatException(record.exc_info)
|
|
168
|
+
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class SimpleFormatter(logging.Formatter):
|
|
173
|
+
"""Simple text formatter with optional components.
|
|
174
|
+
|
|
175
|
+
A more flexible version of the standard Formatter that allows
|
|
176
|
+
enabling/disabling specific fields.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
fmt: Optional[str] = None,
|
|
182
|
+
datefmt: Optional[str] = None,
|
|
183
|
+
include_source: bool = False,
|
|
184
|
+
):
|
|
185
|
+
"""Initialize simple formatter.
|
|
186
|
+
|
|
187
|
+
:param fmt: Format string
|
|
188
|
+
:param datefmt: Date format string
|
|
189
|
+
:param include_source: Whether to include source file/line
|
|
190
|
+
"""
|
|
191
|
+
if fmt is None:
|
|
192
|
+
parts = ["%(asctime)s", "[%(levelname)s]", "%(name)s:", "%(message)s"]
|
|
193
|
+
if include_source:
|
|
194
|
+
parts.append("(%(pathname)s:%(lineno)d)")
|
|
195
|
+
fmt = " ".join(parts)
|
|
196
|
+
|
|
197
|
+
super().__init__(fmt=fmt, datefmt=datefmt)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def configure_formatter(format_type: str = "text", **kwargs) -> logging.Formatter:
|
|
201
|
+
"""Create a configured formatter.
|
|
202
|
+
|
|
203
|
+
Factory function to create formatters based on type.
|
|
204
|
+
|
|
205
|
+
:param format_type: Type of formatter ("text", "json", "colored")
|
|
206
|
+
:param kwargs: Additional arguments for specific formatter types
|
|
207
|
+
:returns: Configured Formatter instance
|
|
208
|
+
|
|
209
|
+
:raises ValueError: If format_type is not recognized
|
|
210
|
+
|
|
211
|
+
Example:
|
|
212
|
+
>>> formatter = configure_formatter("json", indent=2)
|
|
213
|
+
>>> handler = logging.StreamHandler()
|
|
214
|
+
>>> handler.setFormatter(formatter)
|
|
215
|
+
"""
|
|
216
|
+
format_type = format_type.lower()
|
|
217
|
+
|
|
218
|
+
if format_type == "json":
|
|
219
|
+
return JSONFormatter(**kwargs)
|
|
220
|
+
elif format_type == "colored":
|
|
221
|
+
return ColoredTextFormatter(**kwargs)
|
|
222
|
+
elif format_type == "text":
|
|
223
|
+
return SimpleFormatter(**kwargs)
|
|
224
|
+
else:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Unknown format_type: {format_type}. "
|
|
227
|
+
"Choose from: 'text', 'json', 'colored'"
|
|
228
|
+
)
|
etcd3/log_sampling.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Log sampling utilities for etcd3 client.
|
|
2
|
+
|
|
3
|
+
This module provides sampling strategies to control log volume in high-throughput
|
|
4
|
+
scenarios, ensuring that important logs are always captured while reducing noise.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from enum import Enum, auto
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SamplingStrategy(Enum):
|
|
16
|
+
"""Log sampling strategies.
|
|
17
|
+
|
|
18
|
+
- ALWAYS: Always log (no sampling)
|
|
19
|
+
- RATE: Sample at a fixed rate (e.g., 10%)
|
|
20
|
+
- DYNAMIC: Adjust sampling rate based on log volume
|
|
21
|
+
- LEVEL_BASED: Different sampling by log level
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
ALWAYS = auto()
|
|
25
|
+
RATE = auto()
|
|
26
|
+
DYNAMIC = auto()
|
|
27
|
+
LEVEL_BASED = auto()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LogSampler:
|
|
31
|
+
"""Log sampler with multiple strategies.
|
|
32
|
+
|
|
33
|
+
Controls log volume by sampling non-critical logs based on configured
|
|
34
|
+
strategy. Error logs are always emitted regardless of sampling.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> sampler = LogSampler(strategy=SamplingStrategy.RATE, rate=0.1)
|
|
38
|
+
>>> if sampler.should_log(logging.INFO, "kv.get"):
|
|
39
|
+
... logger.info("Operation completed")
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
strategy: SamplingStrategy = SamplingStrategy.ALWAYS,
|
|
45
|
+
rate: float = 1.0,
|
|
46
|
+
burst: int = 100,
|
|
47
|
+
min_interval: float = 0.0,
|
|
48
|
+
level_rates: Optional[dict] = None,
|
|
49
|
+
):
|
|
50
|
+
"""Initialize log sampler.
|
|
51
|
+
|
|
52
|
+
:param strategy: Sampling strategy to use
|
|
53
|
+
:param rate: Sampling rate (0.0-1.0), 1.0 = always
|
|
54
|
+
:param burst: Maximum burst size for dynamic sampling
|
|
55
|
+
:param min_interval: Minimum interval between logs of same type (seconds)
|
|
56
|
+
:param level_rates: Per-level rates for LEVEL_BASED strategy
|
|
57
|
+
"""
|
|
58
|
+
self.strategy = strategy
|
|
59
|
+
self.rate = max(0.0, min(1.0, rate))
|
|
60
|
+
self.burst = burst
|
|
61
|
+
self.min_interval = min_interval
|
|
62
|
+
self.level_rates = level_rates or {
|
|
63
|
+
logging.DEBUG: 0.1,
|
|
64
|
+
logging.INFO: 0.5,
|
|
65
|
+
logging.WARNING: 1.0,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Context-local state for dynamic sampling
|
|
69
|
+
self._counters: ContextVar[dict] = ContextVar("sampler_counters", default={})
|
|
70
|
+
self._timestamps: ContextVar[dict] = ContextVar(
|
|
71
|
+
"sampler_timestamps", default={}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def should_log(
|
|
75
|
+
self,
|
|
76
|
+
level: int,
|
|
77
|
+
operation: Optional[str] = None,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Determine if a log should be emitted.
|
|
80
|
+
|
|
81
|
+
:param level: Log level (from logging module)
|
|
82
|
+
:param operation: Operation identifier for per-operation sampling
|
|
83
|
+
:returns: True if log should be emitted
|
|
84
|
+
"""
|
|
85
|
+
# Errors are always logged
|
|
86
|
+
if level >= logging.ERROR:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
# Apply strategy
|
|
90
|
+
if self.strategy == SamplingStrategy.ALWAYS:
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
if self.strategy == SamplingStrategy.RATE:
|
|
94
|
+
return random.random() < self.rate
|
|
95
|
+
|
|
96
|
+
if self.strategy == SamplingStrategy.LEVEL_BASED:
|
|
97
|
+
level_rate = self.level_rates.get(level, 1.0)
|
|
98
|
+
return random.random() < level_rate
|
|
99
|
+
|
|
100
|
+
if self.strategy == SamplingStrategy.DYNAMIC:
|
|
101
|
+
return self._dynamic_sample(operation)
|
|
102
|
+
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
def _dynamic_sample(self, operation: Optional[str]) -> bool:
|
|
106
|
+
"""Dynamic sampling based on token bucket algorithm.
|
|
107
|
+
|
|
108
|
+
:param operation: Operation identifier
|
|
109
|
+
:returns: True if should log
|
|
110
|
+
"""
|
|
111
|
+
key = operation or "default"
|
|
112
|
+
current_time = time.time()
|
|
113
|
+
|
|
114
|
+
# Check minimum interval
|
|
115
|
+
if self.min_interval > 0:
|
|
116
|
+
timestamps = self._timestamps.get()
|
|
117
|
+
last_time = timestamps.get(key, 0)
|
|
118
|
+
if current_time - last_time < self.min_interval:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
# Token bucket: allow burst, then sample
|
|
122
|
+
counters = self._counters.get()
|
|
123
|
+
count = counters.get(key, 0)
|
|
124
|
+
|
|
125
|
+
if count < self.burst:
|
|
126
|
+
# Within burst allowance
|
|
127
|
+
counters[key] = count + 1
|
|
128
|
+
timestamps = self._timestamps.get()
|
|
129
|
+
timestamps[key] = current_time
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
# Beyond burst, sample at rate
|
|
133
|
+
if random.random() < self.rate:
|
|
134
|
+
timestamps = self._timestamps.get()
|
|
135
|
+
timestamps[key] = current_time
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def reset(self):
|
|
141
|
+
"""Reset sampler state.
|
|
142
|
+
|
|
143
|
+
Clears all counters and timestamps. Useful for testing or when
|
|
144
|
+
log volume characteristics change significantly.
|
|
145
|
+
"""
|
|
146
|
+
self._counters.set({})
|
|
147
|
+
self._timestamps.set({})
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Global sampler instance
|
|
151
|
+
_default_sampler: Optional[LogSampler] = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_sampler() -> LogSampler:
|
|
155
|
+
"""Get the global log sampler instance.
|
|
156
|
+
|
|
157
|
+
Creates a default sampler if none exists.
|
|
158
|
+
|
|
159
|
+
:returns: Global LogSampler instance
|
|
160
|
+
"""
|
|
161
|
+
global _default_sampler
|
|
162
|
+
if _default_sampler is None:
|
|
163
|
+
_default_sampler = LogSampler()
|
|
164
|
+
return _default_sampler
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def set_sampler(sampler: LogSampler):
|
|
168
|
+
"""Set the global log sampler instance.
|
|
169
|
+
|
|
170
|
+
:param sampler: LogSampler instance to use globally
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
>>> new_sampler = LogSampler(strategy=SamplingStrategy.RATE, rate=0.5)
|
|
174
|
+
>>> set_sampler(new_sampler)
|
|
175
|
+
"""
|
|
176
|
+
global _default_sampler
|
|
177
|
+
_default_sampler = sampler
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def should_log(
|
|
181
|
+
level: int,
|
|
182
|
+
operation: Optional[str] = None,
|
|
183
|
+
) -> bool:
|
|
184
|
+
"""Convenience function to check if should log using global sampler.
|
|
185
|
+
|
|
186
|
+
:param level: Log level
|
|
187
|
+
:param operation: Operation identifier
|
|
188
|
+
:returns: True if should log
|
|
189
|
+
"""
|
|
190
|
+
return get_sampler().should_log(level, operation)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def configure_from_env() -> LogSampler:
|
|
194
|
+
"""Configure sampler from environment variables.
|
|
195
|
+
|
|
196
|
+
Environment variables (unified with ETCD_OBS_ prefix):
|
|
197
|
+
- ETCD_OBS_LOG_SAMPLE_STRATEGY: "always", "rate", "dynamic", "level_based"
|
|
198
|
+
- ETCD_OBS_LOG_SAMPLE_RATE: Float between 0.0 and 1.0
|
|
199
|
+
- ETCD_OBS_LOG_SAMPLE_BURST: Integer burst size for dynamic sampling
|
|
200
|
+
|
|
201
|
+
:returns: Configured LogSampler
|
|
202
|
+
"""
|
|
203
|
+
import os
|
|
204
|
+
|
|
205
|
+
ENV_PREFIX = "ETCD_OBS_"
|
|
206
|
+
|
|
207
|
+
strategy_str = os.environ.get(f"{ENV_PREFIX}LOG_SAMPLE_STRATEGY", "always").upper()
|
|
208
|
+
strategy_map = {
|
|
209
|
+
"ALWAYS": SamplingStrategy.ALWAYS,
|
|
210
|
+
"RATE": SamplingStrategy.RATE,
|
|
211
|
+
"DYNAMIC": SamplingStrategy.DYNAMIC,
|
|
212
|
+
"LEVEL_BASED": SamplingStrategy.LEVEL_BASED,
|
|
213
|
+
}
|
|
214
|
+
strategy = strategy_map.get(strategy_str, SamplingStrategy.ALWAYS)
|
|
215
|
+
|
|
216
|
+
rate = float(os.environ.get(f"{ENV_PREFIX}LOG_SAMPLE_RATE", "1.0"))
|
|
217
|
+
burst = int(os.environ.get(f"{ENV_PREFIX}LOG_SAMPLE_BURST", "100"))
|
|
218
|
+
|
|
219
|
+
sampler = LogSampler(strategy=strategy, rate=rate, burst=burst)
|
|
220
|
+
set_sampler(sampler)
|
|
221
|
+
return sampler
|
etcd3/members.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
class Member(object):
|
|
2
|
+
"""
|
|
3
|
+
A member of the etcd cluster.
|
|
4
|
+
|
|
5
|
+
:ivar id: ID of the member
|
|
6
|
+
:ivar name: human-readable name of the member
|
|
7
|
+
:ivar peer_urls: list of URLs the member exposes to the cluster for
|
|
8
|
+
communication
|
|
9
|
+
:ivar client_urls: list of URLs the member exposes to clients for
|
|
10
|
+
communication
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, id, name, peer_urls, client_urls, etcd_client=None):
|
|
14
|
+
self.id = id
|
|
15
|
+
self.name = name
|
|
16
|
+
self.peer_urls = peer_urls
|
|
17
|
+
self.client_urls = client_urls
|
|
18
|
+
self._etcd_client = etcd_client
|
|
19
|
+
|
|
20
|
+
def __str__(self):
|
|
21
|
+
return "Member {id}: peer urls: {peer_urls}, client urls: {client_urls}".format(
|
|
22
|
+
id=self.id, peer_urls=self.peer_urls, client_urls=self.client_urls
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def remove(self):
|
|
26
|
+
"""Remove this member from the cluster."""
|
|
27
|
+
self._etcd_client.remove_member(self.id)
|
|
28
|
+
|
|
29
|
+
def update(self, peer_urls):
|
|
30
|
+
"""
|
|
31
|
+
Update the configuration of this member.
|
|
32
|
+
|
|
33
|
+
:param peer_urls: new list of peer urls the member will use to
|
|
34
|
+
communicate with the cluster
|
|
35
|
+
"""
|
|
36
|
+
self._etcd_client.update_member(self.id, peer_urls)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def active_alarms(self):
|
|
40
|
+
"""Get active alarms of the member.
|
|
41
|
+
|
|
42
|
+
:returns: Alarms
|
|
43
|
+
"""
|
|
44
|
+
return self._etcd_client.list_alarms(member_id=self.id)
|