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/shared.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Shared utilities for etcd3 client.
|
|
2
|
+
|
|
3
|
+
This module contains shared classes and functions used by both
|
|
4
|
+
synchronous and asynchronous etcd3 client implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
|
|
9
|
+
import grpc
|
|
10
|
+
import etcdrpc
|
|
11
|
+
|
|
12
|
+
import etcd3.exceptions as exceptions
|
|
13
|
+
import etcd3.transactions as transactions
|
|
14
|
+
import etcd3.utils as utils
|
|
15
|
+
from etcd3.trace import create_span, get_tracer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_EXCEPTIONS_BY_CODE = {
|
|
19
|
+
grpc.StatusCode.INTERNAL: exceptions.InternalServerError,
|
|
20
|
+
grpc.StatusCode.UNAVAILABLE: exceptions.ConnectionFailedError,
|
|
21
|
+
grpc.StatusCode.DEADLINE_EXCEEDED: exceptions.ConnectionTimeoutError,
|
|
22
|
+
grpc.StatusCode.FAILED_PRECONDITION: exceptions.PreconditionFailedError,
|
|
23
|
+
grpc.StatusCode.RESOURCE_EXHAUSTED: exceptions.ConnectionFailedError,
|
|
24
|
+
grpc.StatusCode.ABORTED: exceptions.PreconditionFailedError,
|
|
25
|
+
grpc.StatusCode.OUT_OF_RANGE: exceptions.RevisionCompactedError,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_FAILED_EP_CODES = [
|
|
29
|
+
grpc.StatusCode.UNAVAILABLE,
|
|
30
|
+
grpc.StatusCode.DEADLINE_EXCEEDED,
|
|
31
|
+
grpc.StatusCode.INTERNAL,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Transactions(object):
|
|
36
|
+
"""Transaction operations for etcd3."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.value = transactions.Value
|
|
40
|
+
self.version = transactions.Version
|
|
41
|
+
self.create = transactions.Create
|
|
42
|
+
self.mod = transactions.Mod
|
|
43
|
+
self.lease = transactions.Lease
|
|
44
|
+
|
|
45
|
+
self.put = transactions.Put
|
|
46
|
+
self.get = transactions.Get
|
|
47
|
+
self.delete = transactions.Delete
|
|
48
|
+
self.txn = transactions.Txn
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class KVMetadata(object):
|
|
52
|
+
"""Metadata for key-value pairs."""
|
|
53
|
+
|
|
54
|
+
__slots__ = (
|
|
55
|
+
"key",
|
|
56
|
+
"value",
|
|
57
|
+
"create_revision",
|
|
58
|
+
"mod_revision",
|
|
59
|
+
"version",
|
|
60
|
+
"lease_id",
|
|
61
|
+
"response_header",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __init__(self, keyvalue, header):
|
|
65
|
+
self.key = keyvalue.key
|
|
66
|
+
self.value = keyvalue.value
|
|
67
|
+
self.create_revision = keyvalue.create_revision
|
|
68
|
+
self.mod_revision = keyvalue.mod_revision
|
|
69
|
+
self.version = keyvalue.version
|
|
70
|
+
self.lease_id = keyvalue.lease
|
|
71
|
+
self.response_header = header
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Status(object):
|
|
75
|
+
"""Etcd cluster status information."""
|
|
76
|
+
|
|
77
|
+
__slots__ = ("version", "db_size", "leader", "raft_index", "raft_term")
|
|
78
|
+
|
|
79
|
+
def __init__(self, version, db_size, leader, raft_index, raft_term):
|
|
80
|
+
self.version = version
|
|
81
|
+
self.db_size = db_size
|
|
82
|
+
self.leader = leader
|
|
83
|
+
self.raft_index = raft_index
|
|
84
|
+
self.raft_term = raft_term
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Alarm(object):
|
|
88
|
+
"""Etcd alarm information."""
|
|
89
|
+
|
|
90
|
+
__slots__ = ("alarm_type", "member_id")
|
|
91
|
+
|
|
92
|
+
def __init__(self, alarm_type, member_id):
|
|
93
|
+
self.alarm_type = alarm_type
|
|
94
|
+
self.member_id = member_id
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class EtcdTokenCallCredentials(grpc.AuthMetadataPlugin):
|
|
98
|
+
"""Metadata wrapper for raw access token credentials."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, access_token):
|
|
101
|
+
self._access_token = access_token
|
|
102
|
+
|
|
103
|
+
def __call__(self, context, callback):
|
|
104
|
+
metadata = (("token", self._access_token),)
|
|
105
|
+
callback(metadata, None)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _handle_errors(payload):
|
|
109
|
+
"""Decorator to handle gRPC errors for synchronous clients."""
|
|
110
|
+
|
|
111
|
+
@functools.wraps(payload)
|
|
112
|
+
def handler(self, *args, **kwargs):
|
|
113
|
+
try:
|
|
114
|
+
return payload(self, *args, **kwargs)
|
|
115
|
+
except grpc.RpcError as exc:
|
|
116
|
+
self._manage_grpc_errors(exc)
|
|
117
|
+
|
|
118
|
+
return handler
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _handle_generator_errors(payload):
|
|
122
|
+
"""Decorator to handle gRPC errors for generators in synchronous clients."""
|
|
123
|
+
|
|
124
|
+
@functools.wraps(payload)
|
|
125
|
+
def handler(self, *args, **kwargs):
|
|
126
|
+
try:
|
|
127
|
+
for item in payload(self, *args, **kwargs):
|
|
128
|
+
yield item
|
|
129
|
+
except grpc.RpcError as exc:
|
|
130
|
+
self._manage_grpc_errors(exc)
|
|
131
|
+
|
|
132
|
+
return handler
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_secure_creds(ca_cert, cert_key=None, cert_cert=None):
|
|
136
|
+
"""Get gRPC secure channel credentials.
|
|
137
|
+
|
|
138
|
+
:param ca_cert: Path to CA certificate file
|
|
139
|
+
:type ca_cert: str
|
|
140
|
+
:param cert_key: Path to client certificate key file
|
|
141
|
+
:type cert_key: str, optional
|
|
142
|
+
:param cert_cert: Path to client certificate file
|
|
143
|
+
:type cert_cert: str, optional
|
|
144
|
+
:return: gRPC channel credentials
|
|
145
|
+
:rtype: grpc.ChannelCredentials
|
|
146
|
+
"""
|
|
147
|
+
cert_key_file = None
|
|
148
|
+
cert_cert_file = None
|
|
149
|
+
|
|
150
|
+
with open(ca_cert, "rb") as f:
|
|
151
|
+
ca_cert_file = f.read()
|
|
152
|
+
|
|
153
|
+
if cert_key is not None:
|
|
154
|
+
with open(cert_key, "rb") as f:
|
|
155
|
+
cert_key_file = f.read()
|
|
156
|
+
|
|
157
|
+
if cert_cert is not None:
|
|
158
|
+
with open(cert_cert, "rb") as f:
|
|
159
|
+
cert_cert_file = f.read()
|
|
160
|
+
|
|
161
|
+
return grpc.ssl_channel_credentials(ca_cert_file, cert_key_file, cert_cert_file)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
EXCEPTIONS_BY_CODE = _EXCEPTIONS_BY_CODE
|
|
165
|
+
FAILED_EP_CODES = _FAILED_EP_CODES
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class RequestBuilder:
|
|
169
|
+
"""Shared request builder for gRPC requests."""
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def build_get_range_request(
|
|
173
|
+
key,
|
|
174
|
+
range_end=None,
|
|
175
|
+
limit=None,
|
|
176
|
+
revision=None,
|
|
177
|
+
sort_order=None,
|
|
178
|
+
sort_target="key",
|
|
179
|
+
serializable=False,
|
|
180
|
+
keys_only=False,
|
|
181
|
+
count_only=False,
|
|
182
|
+
min_mod_revision=None,
|
|
183
|
+
max_mod_revision=None,
|
|
184
|
+
min_create_revision=None,
|
|
185
|
+
max_create_revision=None,
|
|
186
|
+
):
|
|
187
|
+
"""Build a RangeRequest.
|
|
188
|
+
|
|
189
|
+
:param key: The key to query
|
|
190
|
+
:type key: str or bytes
|
|
191
|
+
:param range_end: End of the key range
|
|
192
|
+
:type range_end: str or bytes, optional
|
|
193
|
+
:param limit: Maximum number of keys to return
|
|
194
|
+
:type limit: int, optional
|
|
195
|
+
:param revision: Revision to get
|
|
196
|
+
:type revision: int, optional
|
|
197
|
+
:param sort_order: Sort order ('ascend', 'descend', or None)
|
|
198
|
+
:type sort_order: str, optional
|
|
199
|
+
:param sort_target: Sort target ('key', 'version', 'create', 'mod')
|
|
200
|
+
:type sort_target: str
|
|
201
|
+
:param serializable: Whether to use serializable read
|
|
202
|
+
:type serializable: bool
|
|
203
|
+
:param keys_only: Whether to return only keys
|
|
204
|
+
:type keys_only: bool
|
|
205
|
+
:param count_only: Whether to return only count
|
|
206
|
+
:type count_only: bool
|
|
207
|
+
:param min_mod_revision: Minimum modification revision
|
|
208
|
+
:type min_mod_revision: int, optional
|
|
209
|
+
:param max_mod_revision: Maximum modification revision
|
|
210
|
+
:type max_mod_revision: int, optional
|
|
211
|
+
:param min_create_revision: Minimum creation revision
|
|
212
|
+
:type min_create_revision: int, optional
|
|
213
|
+
:param max_create_revision: Maximum creation revision
|
|
214
|
+
:type max_create_revision: int, optional
|
|
215
|
+
:return: RangeRequest instance
|
|
216
|
+
:rtype: etcdrpc.RangeRequest
|
|
217
|
+
"""
|
|
218
|
+
range_request = etcdrpc.RangeRequest()
|
|
219
|
+
range_request.key = utils.to_bytes(key)
|
|
220
|
+
range_request.keys_only = keys_only
|
|
221
|
+
range_request.count_only = count_only
|
|
222
|
+
range_request.serializable = serializable
|
|
223
|
+
|
|
224
|
+
if range_end is not None:
|
|
225
|
+
range_request.range_end = utils.to_bytes(range_end)
|
|
226
|
+
if limit is not None:
|
|
227
|
+
range_request.limit = limit
|
|
228
|
+
if revision is not None:
|
|
229
|
+
range_request.revision = revision
|
|
230
|
+
if min_mod_revision is not None:
|
|
231
|
+
range_request.min_mod_revision = min_mod_revision
|
|
232
|
+
if max_mod_revision is not None:
|
|
233
|
+
range_request.max_mod_revision = max_mod_revision
|
|
234
|
+
if min_create_revision is not None:
|
|
235
|
+
range_request.min_create_revision = min_create_revision
|
|
236
|
+
if max_create_revision is not None:
|
|
237
|
+
range_request.max_create_revision = max_create_revision
|
|
238
|
+
|
|
239
|
+
sort_orders = {
|
|
240
|
+
None: etcdrpc.RangeRequest.NONE,
|
|
241
|
+
"ascend": etcdrpc.RangeRequest.ASCEND,
|
|
242
|
+
"descend": etcdrpc.RangeRequest.DESCEND,
|
|
243
|
+
}
|
|
244
|
+
request_sort_order = sort_orders.get(sort_order)
|
|
245
|
+
if request_sort_order is None:
|
|
246
|
+
raise ValueError(f'unknown sort order: "{sort_order}"')
|
|
247
|
+
range_request.sort_order = request_sort_order
|
|
248
|
+
|
|
249
|
+
sort_targets = {
|
|
250
|
+
None: etcdrpc.RangeRequest.KEY,
|
|
251
|
+
"key": etcdrpc.RangeRequest.KEY,
|
|
252
|
+
"version": etcdrpc.RangeRequest.VERSION,
|
|
253
|
+
"create": etcdrpc.RangeRequest.CREATE,
|
|
254
|
+
"mod": etcdrpc.RangeRequest.MOD,
|
|
255
|
+
}
|
|
256
|
+
request_sort_target = sort_targets.get(sort_target)
|
|
257
|
+
if request_sort_target is None:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
'sort_target must be one of "key", "version", "create" or "mod"'
|
|
260
|
+
)
|
|
261
|
+
range_request.sort_target = request_sort_target
|
|
262
|
+
|
|
263
|
+
return range_request
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def build_put_request(
|
|
267
|
+
key,
|
|
268
|
+
value=None,
|
|
269
|
+
lease=None,
|
|
270
|
+
prev_kv=False,
|
|
271
|
+
ignore_value=False,
|
|
272
|
+
ignore_lease=False,
|
|
273
|
+
):
|
|
274
|
+
"""Build a PutRequest.
|
|
275
|
+
|
|
276
|
+
:param key: The key to set
|
|
277
|
+
:type key: str or bytes
|
|
278
|
+
:param value: The value to set
|
|
279
|
+
:type value: str or bytes, optional
|
|
280
|
+
:param lease: Lease ID to associate with the key
|
|
281
|
+
:type lease: int, optional
|
|
282
|
+
:param prev_kv: Whether to return the previous key-value pair
|
|
283
|
+
:type prev_kv: bool
|
|
284
|
+
:param ignore_value: Whether to ignore the value field
|
|
285
|
+
:type ignore_value: bool
|
|
286
|
+
:param ignore_lease: Whether to ignore the lease field
|
|
287
|
+
:type ignore_lease: bool
|
|
288
|
+
:return: PutRequest instance
|
|
289
|
+
:rtype: etcdrpc.PutRequest
|
|
290
|
+
"""
|
|
291
|
+
put_request = etcdrpc.PutRequest()
|
|
292
|
+
put_request.key = utils.to_bytes(key)
|
|
293
|
+
if value is not None:
|
|
294
|
+
put_request.value = utils.to_bytes(value)
|
|
295
|
+
put_request.lease = utils.lease_to_id(lease)
|
|
296
|
+
put_request.prev_kv = prev_kv
|
|
297
|
+
put_request.ignore_value = ignore_value
|
|
298
|
+
put_request.ignore_lease = ignore_lease
|
|
299
|
+
return put_request
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def build_delete_request(key, range_end=None, prev_kv=False):
|
|
303
|
+
"""Build a DeleteRangeRequest.
|
|
304
|
+
|
|
305
|
+
:param key: The key to delete
|
|
306
|
+
:type key: str or bytes
|
|
307
|
+
:param range_end: End of the key range (for prefix deletes)
|
|
308
|
+
:type range_end: str or bytes, optional
|
|
309
|
+
:param prev_kv: Whether to return the deleted key-value pairs
|
|
310
|
+
:type prev_kv: bool
|
|
311
|
+
:return: DeleteRangeRequest instance
|
|
312
|
+
:rtype: etcdrpc.DeleteRangeRequest
|
|
313
|
+
"""
|
|
314
|
+
delete_request = etcdrpc.DeleteRangeRequest()
|
|
315
|
+
delete_request.key = utils.to_bytes(key)
|
|
316
|
+
delete_request.prev_kv = prev_kv
|
|
317
|
+
|
|
318
|
+
if range_end is not None:
|
|
319
|
+
delete_request.range_end = utils.to_bytes(range_end)
|
|
320
|
+
|
|
321
|
+
return delete_request
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _make_async_handle_errors_with_tracing(error_handler_func, operation_name: str):
|
|
325
|
+
"""Factory function to create async error handlers with tracing support.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
error_handler_func: A callable that takes (client, exc) to handle errors
|
|
329
|
+
operation_name: Name of the operation for tracing
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
A decorator for async methods that handles gRPC errors and creates spans
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
def decorator(payload):
|
|
336
|
+
@functools.wraps(payload)
|
|
337
|
+
async def handler(self, *args, **kwargs):
|
|
338
|
+
tracer = get_tracer()
|
|
339
|
+
if tracer is None:
|
|
340
|
+
# No tracing available, use basic error handling
|
|
341
|
+
try:
|
|
342
|
+
return await payload(self, *args, **kwargs)
|
|
343
|
+
except grpc.RpcError as exc:
|
|
344
|
+
error_handler_func(self, exc)
|
|
345
|
+
|
|
346
|
+
# Create span for the operation
|
|
347
|
+
with create_span(operation_name, {"operation": operation_name}):
|
|
348
|
+
try:
|
|
349
|
+
return await payload(self, *args, **kwargs)
|
|
350
|
+
except grpc.RpcError as exc:
|
|
351
|
+
error_handler_func(self, exc)
|
|
352
|
+
|
|
353
|
+
return handler
|
|
354
|
+
|
|
355
|
+
return decorator
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _make_async_handle_generator_errors_with_tracing(
|
|
359
|
+
error_handler_func,
|
|
360
|
+
operation_name: str,
|
|
361
|
+
propagate_errors: bool = False,
|
|
362
|
+
):
|
|
363
|
+
"""Factory function to create async generator error handlers with tracing.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
error_handler_func: A callable that takes (client, exc) to handle errors
|
|
367
|
+
operation_name: Name of the operation for tracing
|
|
368
|
+
propagate_errors: If True, re-raise gRPC errors after calling error_handler_func
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
A decorator for async generators that handles gRPC errors and creates spans
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
def decorator(payload):
|
|
375
|
+
@functools.wraps(payload)
|
|
376
|
+
async def handler(self, *args, **kwargs):
|
|
377
|
+
tracer = get_tracer()
|
|
378
|
+
try:
|
|
379
|
+
result = payload(self, *args, **kwargs)
|
|
380
|
+
if hasattr(result, "__aiter__"):
|
|
381
|
+
async for item in result:
|
|
382
|
+
if tracer is not None:
|
|
383
|
+
with create_span(
|
|
384
|
+
operation_name, {"operation": operation_name}
|
|
385
|
+
):
|
|
386
|
+
yield item
|
|
387
|
+
else:
|
|
388
|
+
yield item
|
|
389
|
+
else:
|
|
390
|
+
async for item in await result:
|
|
391
|
+
if tracer is not None:
|
|
392
|
+
with create_span(
|
|
393
|
+
operation_name, {"operation": operation_name}
|
|
394
|
+
):
|
|
395
|
+
yield item
|
|
396
|
+
else:
|
|
397
|
+
yield item
|
|
398
|
+
except grpc.RpcError as exc:
|
|
399
|
+
error_handler_func(self, exc)
|
|
400
|
+
if propagate_errors:
|
|
401
|
+
raise
|
|
402
|
+
except StopIteration:
|
|
403
|
+
# Normal iteration completion, just exit
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
return handler
|
|
407
|
+
|
|
408
|
+
return decorator
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _make_sync_handle_errors_with_tracing(error_handler_func, operation_name: str):
|
|
412
|
+
"""Factory function to create sync error handlers with tracing support.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
error_handler_func: A callable that takes (client, exc) to handle errors
|
|
416
|
+
operation_name: Name of the operation for tracing
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
A decorator for sync methods that handles gRPC errors and creates spans
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
def decorator(payload):
|
|
423
|
+
@functools.wraps(payload)
|
|
424
|
+
def handler(self, *args, **kwargs):
|
|
425
|
+
tracer = get_tracer()
|
|
426
|
+
if tracer is None:
|
|
427
|
+
# No tracing available, use basic error handling
|
|
428
|
+
try:
|
|
429
|
+
return payload(self, *args, **kwargs)
|
|
430
|
+
except grpc.RpcError as exc:
|
|
431
|
+
error_handler_func(self, exc)
|
|
432
|
+
|
|
433
|
+
# Create span for the operation
|
|
434
|
+
with create_span(operation_name, {"operation": operation_name}):
|
|
435
|
+
try:
|
|
436
|
+
return payload(self, *args, **kwargs)
|
|
437
|
+
except grpc.RpcError as exc:
|
|
438
|
+
error_handler_func(self, exc)
|
|
439
|
+
|
|
440
|
+
return handler
|
|
441
|
+
|
|
442
|
+
return decorator
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _make_sync_handle_generator_errors_with_tracing(
|
|
446
|
+
error_handler_func, operation_name: str, propagate_errors: bool = False
|
|
447
|
+
):
|
|
448
|
+
"""Factory function to create sync generator error handlers with tracing.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
error_handler_func: A callable that takes (client, exc) to handle errors
|
|
452
|
+
operation_name: Name of the operation for tracing
|
|
453
|
+
propagate_errors: If True, re-raise gRPC errors after calling error_handler_func
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
A decorator for sync generators that handles gRPC errors and creates spans
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
def decorator(payload):
|
|
460
|
+
@functools.wraps(payload)
|
|
461
|
+
def handler(self, *args, **kwargs):
|
|
462
|
+
tracer = get_tracer()
|
|
463
|
+
if tracer is None:
|
|
464
|
+
# No tracing available, use basic error handling
|
|
465
|
+
try:
|
|
466
|
+
for item in payload(self, *args, **kwargs):
|
|
467
|
+
yield item
|
|
468
|
+
except grpc.RpcError as exc:
|
|
469
|
+
error_handler_func(self, exc)
|
|
470
|
+
if propagate_errors:
|
|
471
|
+
raise
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
# Create span for the operation
|
|
475
|
+
with create_span(operation_name, {"operation": operation_name}):
|
|
476
|
+
try:
|
|
477
|
+
for item in payload(self, *args, **kwargs):
|
|
478
|
+
yield item
|
|
479
|
+
except grpc.RpcError as exc:
|
|
480
|
+
error_handler_func(self, exc)
|
|
481
|
+
if propagate_errors:
|
|
482
|
+
raise
|
|
483
|
+
|
|
484
|
+
return handler
|
|
485
|
+
|
|
486
|
+
return decorator
|
etcd3/trace.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""OpenTelemetry tracing utilities for etcd3 client.
|
|
2
|
+
|
|
3
|
+
This module provides OpenTelemetry tracing support for etcd3 operations.
|
|
4
|
+
It automatically gets the tracer from the global tracer provider.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
|
|
11
|
+
OTEL_AVAILABLE = False
|
|
12
|
+
try:
|
|
13
|
+
from opentelemetry import trace
|
|
14
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
15
|
+
from opentelemetry.trace.propagation.tracecontext import (
|
|
16
|
+
TraceContextTextMapPropagator,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
OTEL_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
_tracer = None
|
|
24
|
+
_propagator = None
|
|
25
|
+
_tracing_initialized = False
|
|
26
|
+
|
|
27
|
+
ENV_PREFIX = "ETCD_OBS_"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def init_tracing(
|
|
31
|
+
service_name: str = "etcd3-client",
|
|
32
|
+
endpoint: Optional[str] = None,
|
|
33
|
+
insecure: bool = True,
|
|
34
|
+
sample_rate: float = 1.0,
|
|
35
|
+
):
|
|
36
|
+
"""Initialize tracing with optional OTLP exporter.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
service_name: Name of the service for tracing
|
|
40
|
+
endpoint: OTLP exporter endpoint (e.g., "localhost:4317")
|
|
41
|
+
insecure: Whether to use insecure connection for OTLP
|
|
42
|
+
sample_rate: Trace sampling rate (0.0-1.0)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if tracing was initialized successfully, False otherwise
|
|
46
|
+
"""
|
|
47
|
+
global _tracer, _propagator, _tracing_initialized
|
|
48
|
+
|
|
49
|
+
if not OTEL_AVAILABLE:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Check if user already set a global provider
|
|
54
|
+
try:
|
|
55
|
+
provider = trace.get_tracer_provider()
|
|
56
|
+
# If user configured their own provider, use it
|
|
57
|
+
if provider and hasattr(provider, "get_tracer"):
|
|
58
|
+
_tracer = provider.get_tracer(service_name)
|
|
59
|
+
_tracing_initialized = True
|
|
60
|
+
return True
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# Try to auto-configure OTLP exporter if endpoint is provided
|
|
65
|
+
if endpoint is None:
|
|
66
|
+
endpoint = os.environ.get(f"{ENV_PREFIX}OTLP_ENDPOINT")
|
|
67
|
+
|
|
68
|
+
if endpoint:
|
|
69
|
+
try:
|
|
70
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
71
|
+
OTLPSpanExporter,
|
|
72
|
+
)
|
|
73
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
74
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
75
|
+
|
|
76
|
+
exporter = OTLPSpanExporter(
|
|
77
|
+
endpoint=endpoint,
|
|
78
|
+
insecure=insecure,
|
|
79
|
+
)
|
|
80
|
+
provider = TracerProvider()
|
|
81
|
+
|
|
82
|
+
# Apply sampling if rate < 1.0
|
|
83
|
+
if sample_rate < 1.0:
|
|
84
|
+
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
|
|
85
|
+
|
|
86
|
+
sampler = TraceIdRatioBased(sample_rate)
|
|
87
|
+
provider = TracerProvider(sampler=sampler)
|
|
88
|
+
|
|
89
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
90
|
+
trace.set_tracer_provider(provider)
|
|
91
|
+
except ImportError:
|
|
92
|
+
pass # OTLP exporter not installed
|
|
93
|
+
elif sample_rate < 1.0:
|
|
94
|
+
# Configure sampler even without OTLP endpoint
|
|
95
|
+
try:
|
|
96
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
97
|
+
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
|
|
98
|
+
|
|
99
|
+
sampler = TraceIdRatioBased(sample_rate)
|
|
100
|
+
provider = TracerProvider(sampler=sampler)
|
|
101
|
+
trace.set_tracer_provider(provider)
|
|
102
|
+
except ImportError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Get tracer from provider
|
|
106
|
+
provider = trace.get_tracer_provider()
|
|
107
|
+
_tracer = provider.get_tracer(service_name)
|
|
108
|
+
_propagator = TraceContextTextMapPropagator()
|
|
109
|
+
_tracing_initialized = True
|
|
110
|
+
return True
|
|
111
|
+
except Exception: # pylint: disable=broad-except
|
|
112
|
+
_tracer = None
|
|
113
|
+
_propagator = None
|
|
114
|
+
_tracing_initialized = False
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_tracer():
|
|
119
|
+
"""Get the tracer instance.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Tracer instance or None if not available
|
|
123
|
+
"""
|
|
124
|
+
global _tracer, _tracing_initialized
|
|
125
|
+
if _tracer is None and OTEL_AVAILABLE and not _tracing_initialized:
|
|
126
|
+
init_tracing()
|
|
127
|
+
return _tracer
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_tracing_available() -> bool:
|
|
131
|
+
"""Check if OpenTelemetry tracing is available.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if OpenTelemetry is installed and initialized, False otherwise
|
|
135
|
+
"""
|
|
136
|
+
return OTEL_AVAILABLE and get_tracer() is not None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@contextmanager
|
|
140
|
+
def create_span(
|
|
141
|
+
operation_name: str, attributes: Optional[Dict[str, Any]] = None, kind=None
|
|
142
|
+
):
|
|
143
|
+
"""Create a span context manager for tracing an operation.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
operation_name: Name of the operation/span
|
|
147
|
+
attributes: Optional attributes to add to the span
|
|
148
|
+
kind: Optional span kind (default: CLIENT)
|
|
149
|
+
|
|
150
|
+
Yields:
|
|
151
|
+
Span object if tracing is available, None otherwise
|
|
152
|
+
"""
|
|
153
|
+
tracer = get_tracer()
|
|
154
|
+
if tracer is None:
|
|
155
|
+
yield None
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if kind is None:
|
|
159
|
+
kind = SpanKind.CLIENT
|
|
160
|
+
|
|
161
|
+
with tracer.start_as_current_span(operation_name, kind=kind) as span:
|
|
162
|
+
if attributes:
|
|
163
|
+
for key, value in attributes.items():
|
|
164
|
+
if value is not None:
|
|
165
|
+
span.set_attribute(key, value)
|
|
166
|
+
try:
|
|
167
|
+
yield span
|
|
168
|
+
span.set_status(Status(StatusCode.OK))
|
|
169
|
+
except Exception as e:
|
|
170
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
171
|
+
span.record_exception(e)
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def inject_context(carrier: dict):
|
|
176
|
+
"""Inject trace context into a carrier dictionary.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
carrier: Dictionary to inject context into
|
|
180
|
+
"""
|
|
181
|
+
if _propagator:
|
|
182
|
+
_propagator.inject(carrier)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def extract_context(carrier: dict):
|
|
186
|
+
"""Extract trace context from a carrier dictionary.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
carrier: Dictionary containing trace context
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Extracted context or None
|
|
193
|
+
"""
|
|
194
|
+
if _propagator:
|
|
195
|
+
return _propagator.extract(carrier)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_current_span():
|
|
200
|
+
"""Get the current active span.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Current span or None
|
|
204
|
+
"""
|
|
205
|
+
if not is_tracing_available():
|
|
206
|
+
return None
|
|
207
|
+
return trace.get_current_span()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def add_span_attribute(key: str, value: Any):
|
|
211
|
+
"""Add an attribute to the current span.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
key: Attribute key
|
|
215
|
+
value: Attribute value
|
|
216
|
+
"""
|
|
217
|
+
span = get_current_span()
|
|
218
|
+
if span:
|
|
219
|
+
span.set_attribute(key, value)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def add_span_event(name: str, attributes: Optional[Dict[str, Any]] = None):
|
|
223
|
+
"""Add an event to the current span.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
name: Event name
|
|
227
|
+
attributes: Optional event attributes
|
|
228
|
+
"""
|
|
229
|
+
span = get_current_span()
|
|
230
|
+
if span:
|
|
231
|
+
span.add_event(name, attributes=attributes or {})
|