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/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 {})