collibra-connector 1.0.19__py3-none-any.whl → 1.1.1__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.
File without changes
@@ -0,0 +1,576 @@
1
+ """
2
+ OpenTelemetry integration for Collibra Connector.
3
+
4
+ This module provides observability capabilities including:
5
+ - Distributed tracing for API calls
6
+ - Metrics for request latency and error rates
7
+ - Automatic instrumentation of all API operations
8
+
9
+ Example:
10
+ >>> from collibra_connector import CollibraConnector
11
+ >>> from collibra_connector.telemetry import enable_telemetry
12
+ >>>
13
+ >>> # Enable telemetry with OTLP exporter
14
+ >>> enable_telemetry(
15
+ ... service_name="my-data-pipeline",
16
+ ... otlp_endpoint="http://localhost:4317"
17
+ ... )
18
+ >>>
19
+ >>> # All API calls are now traced
20
+ >>> conn = CollibraConnector(...)
21
+ >>> assets = conn.asset.find_assets() # Creates a span
22
+
23
+ For Grafana/Prometheus setup:
24
+ >>> enable_telemetry(
25
+ ... service_name="collibra-etl",
26
+ ... otlp_endpoint="http://otel-collector:4317",
27
+ ... enable_metrics=True
28
+ ... )
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import functools
33
+ import logging
34
+ import time
35
+ from contextlib import contextmanager
36
+ from typing import Any, Callable, Dict, Optional, TypeVar, Union
37
+
38
+ # Check for OpenTelemetry availability
39
+ try:
40
+ from opentelemetry import trace, metrics
41
+ from opentelemetry.sdk.trace import TracerProvider
42
+ from opentelemetry.sdk.trace.export import (
43
+ BatchSpanProcessor,
44
+ ConsoleSpanExporter,
45
+ SpanExporter,
46
+ )
47
+ from opentelemetry.sdk.metrics import MeterProvider
48
+ from opentelemetry.sdk.metrics.export import (
49
+ PeriodicExportingMetricReader,
50
+ ConsoleMetricExporter,
51
+ MetricExporter,
52
+ )
53
+ from opentelemetry.sdk.resources import Resource
54
+ from opentelemetry.semconv.resource import ResourceAttributes
55
+ from opentelemetry.trace import Status, StatusCode, Span
56
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
57
+
58
+ OTEL_AVAILABLE = True
59
+ except ImportError:
60
+ OTEL_AVAILABLE = False
61
+ trace = None # type: ignore
62
+ metrics = None # type: ignore
63
+
64
+ # Check for OTLP exporter
65
+ try:
66
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
67
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
68
+ OTLP_AVAILABLE = True
69
+ except ImportError:
70
+ OTLP_AVAILABLE = False
71
+ OTLPSpanExporter = None # type: ignore
72
+ OTLPMetricExporter = None # type: ignore
73
+
74
+
75
+ T = TypeVar('T')
76
+ F = TypeVar('F', bound=Callable[..., Any])
77
+
78
+ # Module-level state
79
+ _tracer: Optional[Any] = None
80
+ _meter: Optional[Any] = None
81
+ _enabled: bool = False
82
+
83
+ # Metrics
84
+ _request_counter: Optional[Any] = None
85
+ _request_duration: Optional[Any] = None
86
+ _error_counter: Optional[Any] = None
87
+
88
+
89
+ def is_telemetry_available() -> bool:
90
+ """Check if OpenTelemetry is installed."""
91
+ return OTEL_AVAILABLE
92
+
93
+
94
+ def is_telemetry_enabled() -> bool:
95
+ """Check if telemetry has been enabled."""
96
+ return _enabled
97
+
98
+
99
+ def enable_telemetry(
100
+ service_name: str = "collibra-connector",
101
+ service_version: str = "1.0.0",
102
+ otlp_endpoint: Optional[str] = None,
103
+ console_export: bool = False,
104
+ enable_metrics: bool = True,
105
+ custom_resource_attributes: Optional[Dict[str, str]] = None
106
+ ) -> bool:
107
+ """
108
+ Enable OpenTelemetry instrumentation for the Collibra Connector.
109
+
110
+ This function sets up tracing and optionally metrics collection
111
+ for all API calls made through the connector.
112
+
113
+ Args:
114
+ service_name: Name of your service for tracing.
115
+ service_version: Version of your service.
116
+ otlp_endpoint: OTLP collector endpoint (e.g., "http://localhost:4317").
117
+ console_export: If True, also export spans to console (for debugging).
118
+ enable_metrics: If True, enable metrics collection.
119
+ custom_resource_attributes: Additional resource attributes.
120
+
121
+ Returns:
122
+ True if telemetry was enabled, False if OpenTelemetry is not installed.
123
+
124
+ Example:
125
+ >>> # For local development with console output
126
+ >>> enable_telemetry(service_name="my-app", console_export=True)
127
+ >>>
128
+ >>> # For production with OTLP collector
129
+ >>> enable_telemetry(
130
+ ... service_name="data-pipeline",
131
+ ... otlp_endpoint="http://otel-collector:4317"
132
+ ... )
133
+ """
134
+ global _tracer, _meter, _enabled
135
+ global _request_counter, _request_duration, _error_counter
136
+
137
+ if not OTEL_AVAILABLE:
138
+ logging.warning(
139
+ "OpenTelemetry not installed. Install with: "
140
+ "pip install opentelemetry-api opentelemetry-sdk"
141
+ )
142
+ return False
143
+
144
+ # Build resource attributes
145
+ resource_attrs = {
146
+ ResourceAttributes.SERVICE_NAME: service_name,
147
+ ResourceAttributes.SERVICE_VERSION: service_version,
148
+ }
149
+ if custom_resource_attributes:
150
+ resource_attrs.update(custom_resource_attributes)
151
+
152
+ resource = Resource.create(resource_attrs)
153
+
154
+ # Set up tracing
155
+ tracer_provider = TracerProvider(resource=resource)
156
+
157
+ # Add exporters
158
+ if otlp_endpoint and OTLP_AVAILABLE:
159
+ otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
160
+ tracer_provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
161
+ elif otlp_endpoint and not OTLP_AVAILABLE:
162
+ logging.warning(
163
+ "OTLP endpoint specified but exporter not installed. "
164
+ "Install with: pip install opentelemetry-exporter-otlp"
165
+ )
166
+
167
+ if console_export:
168
+ tracer_provider.add_span_processor(
169
+ BatchSpanProcessor(ConsoleSpanExporter())
170
+ )
171
+
172
+ trace.set_tracer_provider(tracer_provider)
173
+ _tracer = trace.get_tracer("collibra-connector", service_version)
174
+
175
+ # Set up metrics
176
+ if enable_metrics:
177
+ metric_readers = []
178
+
179
+ if otlp_endpoint and OTLP_AVAILABLE:
180
+ otlp_metric_exporter = OTLPMetricExporter(
181
+ endpoint=otlp_endpoint,
182
+ insecure=True
183
+ )
184
+ metric_readers.append(
185
+ PeriodicExportingMetricReader(otlp_metric_exporter)
186
+ )
187
+
188
+ if console_export:
189
+ metric_readers.append(
190
+ PeriodicExportingMetricReader(ConsoleMetricExporter())
191
+ )
192
+
193
+ if metric_readers:
194
+ meter_provider = MeterProvider(
195
+ resource=resource,
196
+ metric_readers=metric_readers
197
+ )
198
+ metrics.set_meter_provider(meter_provider)
199
+ _meter = metrics.get_meter("collibra-connector", service_version)
200
+
201
+ # Create metrics
202
+ _request_counter = _meter.create_counter(
203
+ name="collibra_requests_total",
204
+ description="Total number of Collibra API requests",
205
+ unit="1"
206
+ )
207
+ _request_duration = _meter.create_histogram(
208
+ name="collibra_request_duration_seconds",
209
+ description="Duration of Collibra API requests",
210
+ unit="s"
211
+ )
212
+ _error_counter = _meter.create_counter(
213
+ name="collibra_errors_total",
214
+ description="Total number of Collibra API errors",
215
+ unit="1"
216
+ )
217
+
218
+ _enabled = True
219
+ logging.info(f"Telemetry enabled for service: {service_name}")
220
+ return True
221
+
222
+
223
+ def disable_telemetry() -> None:
224
+ """Disable telemetry and clean up resources."""
225
+ global _tracer, _meter, _enabled
226
+ global _request_counter, _request_duration, _error_counter
227
+
228
+ _tracer = None
229
+ _meter = None
230
+ _request_counter = None
231
+ _request_duration = None
232
+ _error_counter = None
233
+ _enabled = False
234
+
235
+
236
+ @contextmanager
237
+ def span(
238
+ name: str,
239
+ attributes: Optional[Dict[str, Any]] = None,
240
+ record_exception: bool = True
241
+ ):
242
+ """
243
+ Context manager for creating a traced span.
244
+
245
+ Args:
246
+ name: Name of the span.
247
+ attributes: Optional attributes to add to the span.
248
+ record_exception: If True, record exceptions that occur.
249
+
250
+ Yields:
251
+ The span object (or None if telemetry not enabled).
252
+
253
+ Example:
254
+ >>> with span("process_assets", {"asset_count": 100}) as s:
255
+ ... # Do work
256
+ ... s.set_attribute("processed", 100)
257
+ """
258
+ if not _enabled or not _tracer:
259
+ yield None
260
+ return
261
+
262
+ with _tracer.start_as_current_span(name) as s:
263
+ if attributes:
264
+ for key, value in attributes.items():
265
+ s.set_attribute(key, value)
266
+ try:
267
+ yield s
268
+ except Exception as e:
269
+ if record_exception:
270
+ s.record_exception(e)
271
+ s.set_status(Status(StatusCode.ERROR, str(e)))
272
+ raise
273
+
274
+
275
+ def traced(
276
+ span_name: Optional[str] = None,
277
+ attributes: Optional[Dict[str, Any]] = None
278
+ ) -> Callable[[F], F]:
279
+ """
280
+ Decorator for tracing function calls.
281
+
282
+ Args:
283
+ span_name: Custom span name (defaults to function name).
284
+ attributes: Static attributes to add to every span.
285
+
286
+ Returns:
287
+ Decorated function.
288
+
289
+ Example:
290
+ >>> @traced("fetch_customer_data")
291
+ ... def get_customers(limit: int):
292
+ ... return connector.asset.find_assets(limit=limit)
293
+ """
294
+ def decorator(func: F) -> F:
295
+ @functools.wraps(func)
296
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
297
+ if not _enabled or not _tracer:
298
+ return func(*args, **kwargs)
299
+
300
+ name = span_name or func.__qualname__
301
+ span_attrs = dict(attributes) if attributes else {}
302
+
303
+ # Add function args as attributes (be careful with sensitive data)
304
+ span_attrs["function.name"] = func.__name__
305
+ span_attrs["function.module"] = func.__module__
306
+
307
+ start_time = time.time()
308
+ with _tracer.start_as_current_span(name, attributes=span_attrs) as s:
309
+ try:
310
+ result = func(*args, **kwargs)
311
+
312
+ # Record duration
313
+ duration = time.time() - start_time
314
+ s.set_attribute("duration_seconds", duration)
315
+
316
+ # Record metrics
317
+ if _request_counter:
318
+ _request_counter.add(
319
+ 1,
320
+ {"operation": name, "status": "success"}
321
+ )
322
+ if _request_duration:
323
+ _request_duration.record(
324
+ duration,
325
+ {"operation": name}
326
+ )
327
+
328
+ return result
329
+
330
+ except Exception as e:
331
+ s.record_exception(e)
332
+ s.set_status(Status(StatusCode.ERROR, str(e)))
333
+
334
+ # Record error metric
335
+ if _error_counter:
336
+ _error_counter.add(
337
+ 1,
338
+ {"operation": name, "error_type": type(e).__name__}
339
+ )
340
+
341
+ raise
342
+
343
+ return wrapper # type: ignore
344
+ return decorator
345
+
346
+
347
+ def traced_async(
348
+ span_name: Optional[str] = None,
349
+ attributes: Optional[Dict[str, Any]] = None
350
+ ) -> Callable[[F], F]:
351
+ """
352
+ Decorator for tracing async function calls.
353
+
354
+ Args:
355
+ span_name: Custom span name (defaults to function name).
356
+ attributes: Static attributes to add to every span.
357
+
358
+ Returns:
359
+ Decorated async function.
360
+
361
+ Example:
362
+ >>> @traced_async("async_fetch_data")
363
+ ... async def fetch_data():
364
+ ... return await connector.asset.get_assets_batch(ids)
365
+ """
366
+ def decorator(func: F) -> F:
367
+ @functools.wraps(func)
368
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
369
+ if not _enabled or not _tracer:
370
+ return await func(*args, **kwargs)
371
+
372
+ name = span_name or func.__qualname__
373
+ span_attrs = dict(attributes) if attributes else {}
374
+ span_attrs["function.name"] = func.__name__
375
+ span_attrs["async"] = True
376
+
377
+ start_time = time.time()
378
+ with _tracer.start_as_current_span(name, attributes=span_attrs) as s:
379
+ try:
380
+ result = await func(*args, **kwargs)
381
+
382
+ duration = time.time() - start_time
383
+ s.set_attribute("duration_seconds", duration)
384
+
385
+ if _request_counter:
386
+ _request_counter.add(
387
+ 1,
388
+ {"operation": name, "status": "success"}
389
+ )
390
+ if _request_duration:
391
+ _request_duration.record(duration, {"operation": name})
392
+
393
+ return result
394
+
395
+ except Exception as e:
396
+ s.record_exception(e)
397
+ s.set_status(Status(StatusCode.ERROR, str(e)))
398
+
399
+ if _error_counter:
400
+ _error_counter.add(
401
+ 1,
402
+ {"operation": name, "error_type": type(e).__name__}
403
+ )
404
+
405
+ raise
406
+
407
+ return wrapper # type: ignore
408
+ return decorator
409
+
410
+
411
+ class TracedCollibraConnector:
412
+ """
413
+ Wrapper that adds tracing to all CollibraConnector operations.
414
+
415
+ This class wraps an existing connector and automatically
416
+ traces all API calls.
417
+
418
+ Example:
419
+ >>> from collibra_connector import CollibraConnector
420
+ >>> from collibra_connector.telemetry import TracedCollibraConnector, enable_telemetry
421
+ >>>
422
+ >>> enable_telemetry(service_name="my-app", otlp_endpoint="localhost:4317")
423
+ >>>
424
+ >>> base_conn = CollibraConnector(...)
425
+ >>> conn = TracedCollibraConnector(base_conn)
426
+ >>>
427
+ >>> # All operations are now traced
428
+ >>> assets = conn.asset.find_assets()
429
+ """
430
+
431
+ def __init__(self, connector: Any) -> None:
432
+ """
433
+ Initialize traced connector.
434
+
435
+ Args:
436
+ connector: The CollibraConnector instance to wrap.
437
+ """
438
+ self._connector = connector
439
+ self._wrapped_apis: Dict[str, Any] = {}
440
+
441
+ def __getattr__(self, name: str) -> Any:
442
+ """Get wrapped API module with tracing."""
443
+ if name.startswith('_'):
444
+ return getattr(self._connector, name)
445
+
446
+ if name in self._wrapped_apis:
447
+ return self._wrapped_apis[name]
448
+
449
+ original = getattr(self._connector, name)
450
+ wrapped = TracedAPI(original, name)
451
+ self._wrapped_apis[name] = wrapped
452
+ return wrapped
453
+
454
+
455
+ class TracedAPI:
456
+ """Wrapper that traces all method calls on an API module."""
457
+
458
+ def __init__(self, api: Any, api_name: str) -> None:
459
+ self._api = api
460
+ self._api_name = api_name
461
+
462
+ def __getattr__(self, name: str) -> Any:
463
+ original = getattr(self._api, name)
464
+
465
+ if not callable(original):
466
+ return original
467
+
468
+ @functools.wraps(original)
469
+ def traced_method(*args: Any, **kwargs: Any) -> Any:
470
+ span_name = f"collibra.{self._api_name}.{name}"
471
+ attributes = {
472
+ "collibra.api": self._api_name,
473
+ "collibra.method": name,
474
+ }
475
+
476
+ # Add some kwargs as attributes (filter sensitive ones)
477
+ safe_kwargs = {
478
+ k: str(v)[:100] for k, v in kwargs.items()
479
+ if k not in ('password', 'token', 'secret', 'auth')
480
+ }
481
+ if safe_kwargs:
482
+ attributes["collibra.params"] = str(safe_kwargs)
483
+
484
+ start_time = time.time()
485
+
486
+ if not _enabled or not _tracer:
487
+ return original(*args, **kwargs)
488
+
489
+ with _tracer.start_as_current_span(span_name, attributes=attributes) as s:
490
+ try:
491
+ result = original(*args, **kwargs)
492
+
493
+ duration = time.time() - start_time
494
+ s.set_attribute("duration_seconds", duration)
495
+
496
+ # Add result info
497
+ if isinstance(result, dict):
498
+ if "total" in result:
499
+ s.set_attribute("result.total", result["total"])
500
+ if "results" in result:
501
+ s.set_attribute("result.count", len(result["results"]))
502
+
503
+ if _request_counter:
504
+ _request_counter.add(
505
+ 1,
506
+ {"api": self._api_name, "method": name, "status": "success"}
507
+ )
508
+ if _request_duration:
509
+ _request_duration.record(
510
+ duration,
511
+ {"api": self._api_name, "method": name}
512
+ )
513
+
514
+ return result
515
+
516
+ except Exception as e:
517
+ s.record_exception(e)
518
+ s.set_status(Status(StatusCode.ERROR, str(e)))
519
+
520
+ if _error_counter:
521
+ _error_counter.add(
522
+ 1,
523
+ {
524
+ "api": self._api_name,
525
+ "method": name,
526
+ "error_type": type(e).__name__
527
+ }
528
+ )
529
+
530
+ raise
531
+
532
+ return traced_method
533
+
534
+
535
+ def get_current_trace_id() -> Optional[str]:
536
+ """Get the current trace ID if in a traced context."""
537
+ if not _enabled or not trace:
538
+ return None
539
+
540
+ current_span = trace.get_current_span()
541
+ if current_span:
542
+ return format(current_span.get_span_context().trace_id, '032x')
543
+ return None
544
+
545
+
546
+ def get_current_span_id() -> Optional[str]:
547
+ """Get the current span ID if in a traced context."""
548
+ if not _enabled or not trace:
549
+ return None
550
+
551
+ current_span = trace.get_current_span()
552
+ if current_span:
553
+ return format(current_span.get_span_context().span_id, '016x')
554
+ return None
555
+
556
+
557
+ def add_span_attributes(attributes: Dict[str, Any]) -> None:
558
+ """Add attributes to the current span."""
559
+ if not _enabled or not trace:
560
+ return
561
+
562
+ current_span = trace.get_current_span()
563
+ if current_span:
564
+ for key, value in attributes.items():
565
+ current_span.set_attribute(key, value)
566
+
567
+
568
+ def record_exception(exception: Exception) -> None:
569
+ """Record an exception on the current span."""
570
+ if not _enabled or not trace:
571
+ return
572
+
573
+ current_span = trace.get_current_span()
574
+ if current_span:
575
+ current_span.record_exception(exception)
576
+ current_span.set_status(Status(StatusCode.ERROR, str(exception)))