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/observability.py ADDED
@@ -0,0 +1,431 @@
1
+ """Unified observability configuration for etcd3 client.
2
+
3
+ This module provides a centralized configuration for tracing, logging, and
4
+ metrics, allowing users to enable and configure observability features
5
+ through a single interface.
6
+
7
+ Environment Variables (all prefixed with ETCD_OBS_):
8
+ - ETCD_OBS_AUTO_INIT: Auto-initialize on first client create (default: true)
9
+ - ETCD_OBS_LOG_LEVEL: Log level (DEBUG, INFO, WARNING, ERROR)
10
+ - ETCD_OBS_LOG_FORMAT: Log format (text, json, colored)
11
+ - ETCD_OBS_LOG_SAMPLE_RATE: Log sampling rate (0.0-1.0)
12
+ - ETCD_OBS_LOG_SAMPLE_STRATEGY: Sampling strategy (always, rate, dynamic, level_based)
13
+ - ETCD_OBS_TRACING_ENABLED: Enable OpenTelemetry tracing (true/false)
14
+ - ETCD_OBS_TRACING_SERVICE_NAME: Service name for tracing
15
+ - ETCD_OBS_TRACING_SAMPLE_RATE: Trace sampling rate (0.0-1.0)
16
+ - ETCD_OBS_METRICS_ENABLED: Enable metrics collection (true/false)
17
+ - ETCD_OBS_PROMETHEUS_ENABLED: Enable Prometheus export (true/false)
18
+ """
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Dict, Optional
22
+ import logging
23
+ import os
24
+
25
+ from etcd3.log_sampling import LogSampler, SamplingStrategy, set_sampler
26
+ from etcd3.log_formatters import configure_formatter
27
+
28
+ ENV_PREFIX = "ETCD_OBS_"
29
+
30
+ _observability_initialized: bool = False
31
+
32
+
33
+ def _parse_bool(value: str | None, default: bool) -> bool:
34
+ """Parse boolean from environment variable."""
35
+ if value is None:
36
+ return default
37
+ return value.lower() in ("true", "1", "yes", "on")
38
+
39
+
40
+ def _parse_float(value: str | None, default: float) -> float:
41
+ """Parse float from environment variable."""
42
+ if value is None:
43
+ return default
44
+ try:
45
+ return float(value)
46
+ except (ValueError, TypeError):
47
+ return default
48
+
49
+
50
+ def _parse_strategy(value: str | None) -> SamplingStrategy:
51
+ """Parse sampling strategy from environment variable."""
52
+ if value is None:
53
+ return SamplingStrategy.ALWAYS
54
+ strategy_map = {
55
+ "always": SamplingStrategy.ALWAYS,
56
+ "rate": SamplingStrategy.RATE,
57
+ "dynamic": SamplingStrategy.DYNAMIC,
58
+ "level_based": SamplingStrategy.LEVEL_BASED,
59
+ }
60
+ return strategy_map.get(value.lower(), SamplingStrategy.ALWAYS)
61
+
62
+
63
+ @dataclass
64
+ class ObservabilityConfig:
65
+ """Unified observability configuration.
66
+
67
+ Provides a single point of configuration for all observability features
68
+ including distributed tracing, structured logging, and metrics.
69
+
70
+ Example:
71
+ >>> config = ObservabilityConfig(
72
+ ... tracing_enabled=True,
73
+ ... log_format="json",
74
+ ... log_level="INFO",
75
+ ... )
76
+ >>> config.apply()
77
+
78
+ Attributes:
79
+ tracing_enabled: Enable OpenTelemetry tracing
80
+ tracing_service_name: Service name for tracing
81
+ tracing_sample_rate: Trace sampling rate (0.0-1.0)
82
+ log_level: Log level (DEBUG, INFO, WARNING, ERROR)
83
+ log_format: Log format (text, json, colored)
84
+ log_sample_rate: Log sampling rate (0.0-1.0)
85
+ log_sampling_strategy: Log sampling strategy
86
+ log_level_overrides: Per-operation log level overrides
87
+ metrics_enabled: Enable metrics collection
88
+ inject_trace_to_logs: Include trace context in logs
89
+ """
90
+
91
+ # ===== Tracing Configuration =====
92
+ tracing_enabled: bool = True
93
+ tracing_service_name: str = "etcd3-client"
94
+ tracing_sample_rate: float = 1.0
95
+
96
+ # ===== Logging Configuration =====
97
+ log_level: str = "INFO"
98
+ log_format: str = "text" # text, json, colored
99
+ log_sample_rate: float = 1.0
100
+ log_sampling_strategy: SamplingStrategy = SamplingStrategy.ALWAYS
101
+ log_level_overrides: Dict[str, str] = field(default_factory=dict)
102
+
103
+ # ===== Metrics Configuration =====
104
+ metrics_enabled: bool = True
105
+ prometheus_enabled: bool = False
106
+
107
+ # ===== Integration Configuration =====
108
+ inject_trace_to_logs: bool = True
109
+
110
+ def __post_init__(self):
111
+ """Validate configuration and initialize components."""
112
+ self._validate()
113
+ self._init_sampler()
114
+
115
+ def _validate(self):
116
+ """Validate configuration values.
117
+
118
+ :raises ValueError: If configuration is invalid
119
+ """
120
+ valid_formats = ["text", "json", "colored"]
121
+ if self.log_format not in valid_formats:
122
+ raise ValueError(
123
+ f"log_format must be one of {valid_formats}, got '{self.log_format}'"
124
+ )
125
+
126
+ if not 0.0 <= self.tracing_sample_rate <= 1.0:
127
+ raise ValueError(
128
+ f"tracing_sample_rate must be between 0.0 and 1.0, "
129
+ f"got {self.tracing_sample_rate}"
130
+ )
131
+
132
+ if not 0.0 <= self.log_sample_rate <= 1.0:
133
+ raise ValueError(
134
+ f"log_sample_rate must be between 0.0 and 1.0, "
135
+ f"got {self.log_sample_rate}"
136
+ )
137
+
138
+ # Validate log level
139
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
140
+ if self.log_level.upper() not in valid_levels:
141
+ raise ValueError(
142
+ f"log_level must be one of {valid_levels}, got '{self.log_level}'"
143
+ )
144
+
145
+ def _init_sampler(self):
146
+ """Initialize log sampler based on configuration."""
147
+ self._sampler = LogSampler(
148
+ strategy=self.log_sampling_strategy,
149
+ rate=self.log_sample_rate,
150
+ )
151
+
152
+ def apply(self):
153
+ """Apply configuration to all observability components.
154
+
155
+ This method configures:
156
+ - Logging level and format
157
+ - Tracing initialization
158
+ - Log sampling
159
+ - Prometheus metrics (if enabled)
160
+
161
+ Example:
162
+ >>> config = ObservabilityConfig(log_format="json")
163
+ >>> config.apply()
164
+ >>> # Now all etcd3 logs will be in JSON format
165
+ """
166
+ from etcd3.log import get_logger, set_log_level
167
+
168
+ # 1. Configure log level
169
+ set_log_level(self.log_level)
170
+
171
+ # 2. Configure log format
172
+ logger = get_logger()
173
+
174
+ # Clear existing handlers and add new one
175
+ logger.handlers.clear()
176
+ handler = logging.StreamHandler()
177
+ handler.setFormatter(configure_formatter(self.log_format))
178
+ logger.addHandler(handler)
179
+
180
+ # 3. Initialize tracing if enabled
181
+ if self.tracing_enabled:
182
+ from etcd3.trace import init_tracing
183
+
184
+ init_tracing(
185
+ service_name=self.tracing_service_name,
186
+ sample_rate=self.tracing_sample_rate,
187
+ )
188
+
189
+ # 4. Set global log sampler
190
+ set_sampler(self._sampler)
191
+
192
+ # 5. Initialize Prometheus metrics if enabled
193
+ if self.prometheus_enabled:
194
+ self._init_prometheus()
195
+
196
+ # Mark as initialized
197
+ global _observability_initialized
198
+ _observability_initialized = True
199
+
200
+ def _init_prometheus(self):
201
+ """Initialize Prometheus exporter."""
202
+ try:
203
+ from etcd3.exporters.prometheus import (
204
+ init_exporter,
205
+ is_prometheus_available,
206
+ )
207
+
208
+ if not is_prometheus_available():
209
+ import logging
210
+
211
+ logging.getLogger("etcd3").warning(
212
+ "prometheus_client not installed. "
213
+ "Install with: pip install prometheus-client"
214
+ )
215
+ return
216
+
217
+ init_exporter()
218
+ except Exception as e:
219
+ import logging
220
+
221
+ logging.getLogger("etcd3").warning(
222
+ f"Failed to initialize Prometheus exporter: {e}"
223
+ )
224
+
225
+ def get_prometheus_metrics(self) -> Optional[bytes]:
226
+ """Get Prometheus metrics in text format.
227
+
228
+ Returns:
229
+ Prometheus metrics text format or None if not available
230
+ """
231
+ if not self.prometheus_enabled:
232
+ return None
233
+
234
+ try:
235
+ from etcd3.exporters.prometheus import get_exporter
236
+
237
+ exporter = get_exporter()
238
+ if exporter:
239
+ return exporter.metrics()
240
+ except Exception:
241
+ pass
242
+ return None
243
+
244
+ def get_log_level_for_operation(self, operation: str) -> int:
245
+ """Get the effective log level for a specific operation.
246
+
247
+ Checks for operation-specific overrides before returning the
248
+ default log level.
249
+
250
+ :param operation: Operation name (e.g., "kv.get", "lease.keepalive")
251
+ :returns: Log level as integer (logging.DEBUG, etc.)
252
+
253
+ Example:
254
+ >>> config = ObservabilityConfig(
255
+ ... log_level="INFO",
256
+ ... log_level_overrides={"lease.keepalive": "WARNING"}
257
+ ... )
258
+ >>> config.get_log_level_for_operation("kv.get")
259
+ 20 # INFO
260
+ >>> config.get_log_level_for_operation("lease.keepalive")
261
+ 30 # WARNING
262
+ """
263
+ level_str = self.log_level_overrides.get(operation)
264
+ if level_str:
265
+ return getattr(logging, level_str.upper(), logging.INFO)
266
+ return getattr(logging, self.log_level.upper(), logging.INFO)
267
+
268
+ def should_log_operation(self, operation: str, level: int) -> bool:
269
+ """Check if an operation should be logged at the given level.
270
+
271
+ Combines operation-specific level configuration with global
272
+ sampling strategy.
273
+
274
+ :param operation: Operation name
275
+ :param level: Log level to check
276
+ :returns: True if should log
277
+ """
278
+ effective_level = self.get_log_level_for_operation(operation)
279
+ if level < effective_level:
280
+ return False
281
+
282
+ return self._sampler.should_log(level, operation)
283
+
284
+
285
+ def get_default_config() -> ObservabilityConfig:
286
+ """Get default observability configuration.
287
+
288
+ Creates a configuration with sensible defaults suitable for
289
+ most production deployments.
290
+
291
+ :returns: Default ObservabilityConfig
292
+
293
+ Example:
294
+ >>> config = get_default_config()
295
+ >>> config.apply()
296
+ """
297
+ return ObservabilityConfig(
298
+ tracing_enabled=True,
299
+ tracing_service_name="etcd3-client",
300
+ log_level="INFO",
301
+ log_format="text",
302
+ log_sample_rate=1.0,
303
+ metrics_enabled=True,
304
+ )
305
+
306
+
307
+ def get_development_config() -> ObservabilityConfig:
308
+ """Get development-friendly observability configuration.
309
+
310
+ Creates a configuration optimized for development with verbose
311
+ logging and full tracing.
312
+
313
+ :returns: Development ObservabilityConfig
314
+
315
+ Example:
316
+ >>> config = get_development_config()
317
+ >>> config.apply()
318
+ """
319
+ return ObservabilityConfig(
320
+ tracing_enabled=True,
321
+ tracing_service_name="etcd3-client-dev",
322
+ log_level="DEBUG",
323
+ log_format="colored",
324
+ log_sample_rate=1.0,
325
+ metrics_enabled=True,
326
+ )
327
+
328
+
329
+ def get_production_config() -> ObservabilityConfig:
330
+ """Get production-optimized observability configuration.
331
+
332
+ Creates a configuration optimized for production with structured
333
+ logging, sampling, and moderate log levels.
334
+
335
+ :returns: Production ObservabilityConfig
336
+
337
+ Example:
338
+ >>> config = get_production_config()
339
+ >>> config.apply()
340
+ """
341
+ return ObservabilityConfig(
342
+ tracing_enabled=True,
343
+ tracing_service_name="etcd3-client",
344
+ tracing_sample_rate=0.1, # Sample 10% of traces
345
+ log_level="INFO",
346
+ log_format="json",
347
+ log_sample_rate=0.5, # Sample 50% of logs
348
+ log_sampling_strategy=SamplingStrategy.RATE,
349
+ log_level_overrides={
350
+ "lease.keepalive": "WARNING", # Reduce keepalive noise
351
+ },
352
+ metrics_enabled=True,
353
+ prometheus_enabled=True,
354
+ )
355
+
356
+
357
+ def configure_from_env() -> ObservabilityConfig:
358
+ """Create configuration from environment variables.
359
+
360
+ All environment variables are prefixed with ETCD_OBS_:
361
+ - ETCD_OBS_AUTO_INIT: Auto-initialize on first client create (default: true)
362
+ - ETCD_OBS_LOG_LEVEL: DEBUG, INFO, WARNING, ERROR
363
+ - ETCD_OBS_LOG_FORMAT: text, json, colored
364
+ - ETCD_OBS_LOG_SAMPLE_RATE: Float 0.0-1.0
365
+ - ETCD_OBS_LOG_SAMPLE_STRATEGY: always, rate, dynamic, level_based
366
+ - ETCD_OBS_TRACING_ENABLED: true or false
367
+ - ETCD_OBS_TRACING_SERVICE_NAME: Service name
368
+ - ETCD_OBS_TRACING_SAMPLE_RATE: Float 0.0-1.0
369
+ - ETCD_OBS_METRICS_ENABLED: true or false
370
+ - ETCD_OBS_PROMETHEUS_ENABLED: true or false
371
+
372
+ :returns: Configured ObservabilityConfig
373
+ """
374
+ return ObservabilityConfig(
375
+ log_level=os.environ.get(f"{ENV_PREFIX}LOG_LEVEL", "INFO"),
376
+ log_format=os.environ.get(f"{ENV_PREFIX}LOG_FORMAT", "text"),
377
+ log_sample_rate=_parse_float(
378
+ os.environ.get(f"{ENV_PREFIX}LOG_SAMPLE_RATE"), 1.0
379
+ ),
380
+ log_sampling_strategy=_parse_strategy(
381
+ os.environ.get(f"{ENV_PREFIX}LOG_SAMPLE_STRATEGY")
382
+ ),
383
+ tracing_enabled=_parse_bool(
384
+ os.environ.get(f"{ENV_PREFIX}TRACING_ENABLED"), True
385
+ ),
386
+ tracing_service_name=os.environ.get(
387
+ f"{ENV_PREFIX}TRACING_SERVICE_NAME", "etcd3-client"
388
+ ),
389
+ tracing_sample_rate=_parse_float(
390
+ os.environ.get(f"{ENV_PREFIX}TRACING_SAMPLE_RATE"), 1.0
391
+ ),
392
+ metrics_enabled=_parse_bool(
393
+ os.environ.get(f"{ENV_PREFIX}METRICS_ENABLED"), True
394
+ ),
395
+ prometheus_enabled=_parse_bool(
396
+ os.environ.get(f"{ENV_PREFIX}PROMETHEUS_ENABLED"), False
397
+ ),
398
+ )
399
+
400
+
401
+ def auto_init() -> bool:
402
+ """Automatically initialize observability based on environment variables.
403
+
404
+ This function is called on first client creation to set up logging,
405
+ tracing, and metrics if not already configured.
406
+
407
+ Returns:
408
+ True if initialization was performed, False if already initialized
409
+ """
410
+ global _observability_initialized
411
+
412
+ if _observability_initialized:
413
+ return False
414
+
415
+ # Check if auto-init is enabled (default: true)
416
+ if not _parse_bool(os.environ.get(f"{ENV_PREFIX}AUTO_INIT"), True):
417
+ return False
418
+
419
+ config = configure_from_env()
420
+ config.apply()
421
+ _observability_initialized = True
422
+ return True
423
+
424
+
425
+ def is_initialized() -> bool:
426
+ """Check if observability has been initialized.
427
+
428
+ Returns:
429
+ True if observability has been initialized
430
+ """
431
+ return _observability_initialized