rebrandly-otel 0.3.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.
@@ -0,0 +1,505 @@
1
+ # http_utils.py
2
+ """Shared HTTP utilities for Rebrandly OTEL SDK."""
3
+
4
+ import os
5
+ import json
6
+ import re
7
+ from typing import Any, Dict, Optional
8
+
9
+ from opentelemetry import context, propagate
10
+
11
+
12
+ # ============================================
13
+ # CONSTANTS
14
+ # ============================================
15
+
16
+ # Sensitive field names to redact from request bodies (case-insensitive matching)
17
+ SENSITIVE_FIELD_NAMES = [
18
+ 'password',
19
+ 'passwd',
20
+ 'pwd',
21
+ 'token',
22
+ 'access_token',
23
+ 'accesstoken',
24
+ 'refresh_token',
25
+ 'refreshtoken',
26
+ 'auth_token',
27
+ 'authtoken',
28
+ 'apikey',
29
+ 'api_key',
30
+ 'api-key',
31
+ 'secret',
32
+ 'client_secret',
33
+ 'clientsecret',
34
+ 'authorization',
35
+ 'creditcard',
36
+ 'credit_card',
37
+ 'cardnumber',
38
+ 'card_number',
39
+ 'cvv',
40
+ 'cvc',
41
+ 'ssn',
42
+ 'social_security',
43
+ 'socialsecurity'
44
+ ]
45
+
46
+ # Content types that should be captured (JSON only)
47
+ CAPTURABLE_CONTENT_TYPES = [
48
+ 'application/json',
49
+ 'application/ld+json',
50
+ 'application/vnd.api+json'
51
+ ]
52
+
53
+
54
+ # ============================================
55
+ # ROUTE PATTERN DETECTION
56
+ # ============================================
57
+
58
+ def auto_detect_route_pattern(path: str) -> str:
59
+ """
60
+ Automatically detect and normalize route patterns using heuristics.
61
+ Replaces common ID patterns (UUIDs, hex strings, numeric IDs) with placeholders.
62
+
63
+ Critical for maintaining low cardinality in telemetry data per OpenTelemetry spec.
64
+
65
+ Args:
66
+ path: The actual request path (e.g., '/users/550e8400-e29b-41d4-a716-446655440000')
67
+
68
+ Returns:
69
+ str: Normalized route pattern (e.g., '/users/{id}')
70
+
71
+ Example:
72
+ auto_detect_route_pattern('/users/550e8400-e29b-41d4-a716-446655440000')
73
+ # Returns: '/users/{id}'
74
+
75
+ auto_detect_route_pattern('/templates/0301fb0436d949979b6688a5c6d91e8f/schedule')
76
+ # Returns: '/templates/{id}/schedule'
77
+ """
78
+ pattern = path
79
+
80
+ # Replace UUIDs (8-4-4-4-12 hex format)
81
+ # Example: /users/550e8400-e29b-41d4-a716-446655440000/posts -> /users/{id}/posts
82
+ pattern = re.sub(
83
+ r'/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(/|$)',
84
+ r'/{id}\1', pattern, flags=re.IGNORECASE
85
+ )
86
+
87
+ # Replace 32-character hex strings (MongoDB ObjectId without dashes)
88
+ # Example: /templates/0301fb0436d949979b6688a5c6d91e8f/schedule -> /templates/{id}/schedule
89
+ pattern = re.sub(r'/[0-9a-f]{32}(/|$)', r'/{id}\1', pattern, flags=re.IGNORECASE)
90
+
91
+ # Replace 24-character hex strings (MongoDB ObjectId)
92
+ # Example: /users/507f1f77bcf86cd799439011/posts -> /users/{id}/posts
93
+ pattern = re.sub(r'/[0-9a-f]{24}(/|$)', r'/{id}\1', pattern, flags=re.IGNORECASE)
94
+
95
+ # Replace 40-character hex strings (SHA-1 hashes)
96
+ pattern = re.sub(r'/[0-9a-f]{40}(/|$)', r'/{id}\1', pattern, flags=re.IGNORECASE)
97
+
98
+ # Replace other long hex strings (16+ chars)
99
+ pattern = re.sub(r'/[0-9a-f]{16,}(/|$)', r'/{id}\1', pattern, flags=re.IGNORECASE)
100
+
101
+ # Replace numeric IDs (4+ digits, but not version numbers like /v1, /v2)
102
+ # Example: /users/12345/posts -> /users/{id}/posts
103
+ pattern = re.sub(r'/(\d{4,})(/|$)', r'/{id}\2', pattern)
104
+
105
+ # Replace shorter numeric IDs in typical ID positions (after nouns)
106
+ # Example: /items/123/details -> /items/{id}/details
107
+ # But preserve /v1, /v2, /api/v1, etc.
108
+ def replace_short_ids(match):
109
+ noun, num, trailing = match.groups()
110
+ # Don't replace version numbers
111
+ if noun in ('v', 'api'):
112
+ return match.group(0)
113
+ return f'/{noun}/{{id}}{trailing}'
114
+
115
+ pattern = re.sub(r'/([\w-]+)/(\d{1,3})(/|$)', replace_short_ids, pattern)
116
+
117
+ return pattern
118
+
119
+
120
+ def reconstruct_route_pattern(path: str, params: Dict[str, Any]) -> str:
121
+ """
122
+ Reconstruct route pattern from actual path and extracted params.
123
+ Replaces parameter values with their keys to create a low-cardinality template.
124
+
125
+ Args:
126
+ path: The actual request path (e.g., '/users/123/posts/456')
127
+ params: Route params dict (e.g., {'user_id': '123', 'post_id': '456'})
128
+
129
+ Returns:
130
+ str: Route pattern (e.g., '/users/{user_id}/posts/{post_id}')
131
+
132
+ Example:
133
+ reconstruct_route_pattern('/users/123/posts/456', {'user_id': '123', 'post_id': '456'})
134
+ # Returns: '/users/{user_id}/posts/{post_id}'
135
+ """
136
+ if not params:
137
+ return path
138
+
139
+ pattern = path
140
+
141
+ # Sort by value length descending to avoid partial replacements
142
+ sorted_params = sorted(
143
+ params.items(),
144
+ key=lambda x: -len(str(x[1])) if x[1] is not None else 0
145
+ )
146
+
147
+ for key, value in sorted_params:
148
+ if value is not None:
149
+ # Use word boundaries to avoid partial replacements
150
+ pattern = re.sub(
151
+ rf'/{re.escape(str(value))}(/|$)',
152
+ rf'/{{{key}}}\1',
153
+ pattern
154
+ )
155
+
156
+ return pattern
157
+
158
+
159
+ # ============================================
160
+ # HEADER FILTERING
161
+ # ============================================
162
+
163
+ def filter_important_headers(headers):
164
+ """
165
+ Filter headers to keep only important ones for observability.
166
+ Excludes sensitive headers like authorization, cookies, and tokens.
167
+ """
168
+ important_headers = [
169
+ 'content-type',
170
+ 'content-length',
171
+ 'accept',
172
+ 'accept-encoding',
173
+ 'accept-language',
174
+ 'host',
175
+ 'x-forwarded-for',
176
+ 'x-forwarded-proto',
177
+ 'x-request-id',
178
+ 'x-correlation-id',
179
+ 'x-trace-id',
180
+ 'user-agent',
181
+ 'traceparent',
182
+ 'tracestate'
183
+ ]
184
+
185
+ filtered = {}
186
+ for key, value in headers.items():
187
+ if key.lower() in important_headers:
188
+ filtered[key] = value
189
+ return filtered
190
+
191
+
192
+ # ============================================
193
+ # REQUEST BODY CAPTURE
194
+ # ============================================
195
+
196
+ def is_body_capture_enabled() -> bool:
197
+ """
198
+ Check if request body capture is enabled.
199
+ Enabled by default (opt-out model), can be disabled via environment variable.
200
+
201
+ Returns:
202
+ bool: True if body capture is enabled
203
+
204
+ Example:
205
+ # Disable body capture
206
+ os.environ['OTEL_CAPTURE_REQUEST_BODY'] = 'false'
207
+ is_body_capture_enabled() # Returns: False
208
+ """
209
+ env_value = os.environ.get('OTEL_CAPTURE_REQUEST_BODY', '').lower()
210
+ if not env_value:
211
+ return True # Enabled by default
212
+ return env_value not in ('false', '0', 'no')
213
+
214
+
215
+ def should_capture_body(content_type: Optional[str]) -> bool:
216
+ """
217
+ Check if content type should be captured.
218
+ Only JSON content types are captured (application/json and variants).
219
+
220
+ Args:
221
+ content_type: Content-Type header value
222
+
223
+ Returns:
224
+ bool: True if content type should be captured
225
+
226
+ Example:
227
+ should_capture_body('application/json') # Returns: True
228
+ should_capture_body('application/json; charset=utf-8') # Returns: True
229
+ should_capture_body('text/html') # Returns: False
230
+ should_capture_body('multipart/form-data') # Returns: False
231
+ """
232
+ if not content_type or not isinstance(content_type, str):
233
+ return False
234
+
235
+ # Extract base content type (before semicolon for charset, etc.)
236
+ base_content_type = content_type.split(';')[0].strip().lower()
237
+
238
+ # Check if it matches any capturable content type
239
+ return any(
240
+ base_content_type == ct or base_content_type.endswith('+json')
241
+ for ct in CAPTURABLE_CONTENT_TYPES
242
+ )
243
+
244
+
245
+ def redact_sensitive_fields(obj: Any) -> Any:
246
+ """
247
+ Recursively redact sensitive fields from an object.
248
+ Creates a deep copy to avoid mutating the original object.
249
+
250
+ Args:
251
+ obj: Object to redact (can be dict, list, or primitive)
252
+
253
+ Returns:
254
+ Any: Redacted copy of the object
255
+
256
+ Example:
257
+ data = {'username': 'john', 'password': 'secret123', 'nested': {'token': 'abc'}}
258
+ redact_sensitive_fields(data)
259
+ # Returns: {'username': 'john', 'password': '[REDACTED]', 'nested': {'token': '[REDACTED]'}}
260
+ """
261
+ # Handle None and primitives
262
+ if obj is None or not isinstance(obj, (dict, list)):
263
+ return obj
264
+
265
+ # Handle lists
266
+ if isinstance(obj, list):
267
+ return [redact_sensitive_fields(item) for item in obj]
268
+
269
+ # Handle dictionaries
270
+ redacted = {}
271
+ for key, value in obj.items():
272
+ lower_key = key.lower() if isinstance(key, str) else str(key).lower()
273
+
274
+ # Check if key matches any sensitive field name
275
+ # Only match if the key exactly matches or contains the sensitive name
276
+ # (not if the sensitive name contains the key, to avoid false positives like "auth" matching "auth_token")
277
+ is_sensitive = any(
278
+ lower_key == sensitive_name or
279
+ sensitive_name in lower_key
280
+ for sensitive_name in SENSITIVE_FIELD_NAMES
281
+ )
282
+
283
+ if is_sensitive:
284
+ redacted[key] = '[REDACTED]'
285
+ elif isinstance(value, (dict, list)):
286
+ # Recursively redact nested objects and arrays
287
+ redacted[key] = redact_sensitive_fields(value)
288
+ else:
289
+ redacted[key] = value
290
+
291
+ return redacted
292
+
293
+
294
+ def capture_request_body(body: Any, content_type: Optional[str]) -> Optional[str]:
295
+ """
296
+ Capture and process request body for telemetry.
297
+ Handles JSON parsing, content-type filtering, and sensitive data redaction.
298
+
299
+ Args:
300
+ body: Request body (can be string, dict, bytes, or other)
301
+ content_type: Content-Type header value
302
+
303
+ Returns:
304
+ Optional[str]: Processed body as JSON string, or None if not capturable
305
+
306
+ Example:
307
+ # With parsed dict body
308
+ body = {'user': 'john', 'password': 'secret'}
309
+ capture_request_body(body, 'application/json')
310
+ # Returns: '{"user":"john","password":"[REDACTED]"}'
311
+
312
+ # With string body
313
+ body = '{"user":"john","password":"secret"}'
314
+ capture_request_body(body, 'application/json')
315
+ # Returns: '{"user":"john","password":"[REDACTED]"}'
316
+
317
+ # With non-JSON content type
318
+ capture_request_body(body, 'text/html')
319
+ # Returns: None
320
+ """
321
+ try:
322
+ # Check if body capture is enabled
323
+ if not is_body_capture_enabled():
324
+ return None
325
+
326
+ # Check if content type should be captured
327
+ if not should_capture_body(content_type):
328
+ return None
329
+
330
+ # Handle empty body
331
+ if not body:
332
+ return None
333
+
334
+ # Parse body if it's a string or bytes
335
+ parsed_body = body
336
+ if isinstance(body, str):
337
+ try:
338
+ parsed_body = json.loads(body)
339
+ except (json.JSONDecodeError, ValueError):
340
+ # If parsing fails, return None (invalid JSON)
341
+ return None
342
+ elif isinstance(body, bytes):
343
+ try:
344
+ parsed_body = json.loads(body.decode('utf-8'))
345
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
346
+ return None
347
+
348
+ # Redact sensitive fields
349
+ redacted = redact_sensitive_fields(parsed_body)
350
+
351
+ # Convert back to JSON string
352
+ return json.dumps(redacted)
353
+ except Exception as e:
354
+ # Silently fail - don't break the request if body capture fails
355
+ print(f'[Rebrandly OTEL] Body capture failed: {str(e)}')
356
+ return None
357
+
358
+
359
+ # ============================================
360
+ # TRACEPARENT INJECTION UTILITIES
361
+ # ============================================
362
+
363
+ def get_traceparent_header() -> Dict[str, str]:
364
+ """
365
+ Get the current trace context as a traceparent header dict.
366
+ Returns a dict with 'traceparent' key for easy spreading into headers.
367
+
368
+ Returns:
369
+ Dict with traceparent header {'traceparent': '00-...'} or empty dict if no active span
370
+
371
+ Example:
372
+ headers = {**get_traceparent_header(), 'Content-Type': 'application/json'}
373
+ requests.get(url, headers=headers)
374
+ """
375
+ try:
376
+ active_context = context.get_current()
377
+ headers = {}
378
+ propagate.inject(headers, active_context)
379
+
380
+ if 'traceparent' in headers:
381
+ return {'traceparent': headers['traceparent']}
382
+ return {}
383
+ except Exception as e:
384
+ print(f'[Rebrandly OTEL] Failed to get traceparent header: {str(e)}')
385
+ return {}
386
+
387
+
388
+ def inject_traceparent(headers: Dict[str, Any]) -> Dict[str, Any]:
389
+ """
390
+ Inject the current trace context into an existing headers dict.
391
+ Modifies the headers dict in place by adding the traceparent header.
392
+
393
+ Args:
394
+ headers: Headers dict to inject traceparent into
395
+
396
+ Returns:
397
+ The modified headers dict (for chaining)
398
+
399
+ Example:
400
+ headers = {'Content-Type': 'application/json'}
401
+ inject_traceparent(headers)
402
+ requests.get(url, headers=headers)
403
+ """
404
+ if not isinstance(headers, dict):
405
+ print('[Rebrandly OTEL] Invalid headers dict provided to inject_traceparent')
406
+ return headers
407
+
408
+ try:
409
+ active_context = context.get_current()
410
+ propagate.inject(headers, active_context)
411
+ except Exception as e:
412
+ print(f'[Rebrandly OTEL] Failed to inject traceparent: {str(e)}')
413
+
414
+ return headers
415
+
416
+
417
+ def requests_with_tracing(session=None):
418
+ """
419
+ Create a requests Session or enhance existing session with automatic traceparent injection.
420
+ Uses request hooks to inject traceparent header into all outgoing requests.
421
+
422
+ Args:
423
+ session: Optional requests Session to enhance. If not provided, creates a new one.
424
+
425
+ Returns:
426
+ requests.Session with traceparent injection hook
427
+
428
+ Example:
429
+ # Option 1: Create new session with tracing
430
+ session = requests_with_tracing()
431
+ response = session.get('https://api.example.com/users')
432
+
433
+ # Option 2: Enhance existing session
434
+ import requests
435
+ my_session = requests.Session()
436
+ traced_session = requests_with_tracing(my_session)
437
+ """
438
+ try:
439
+ import requests
440
+ except ImportError:
441
+ raise ImportError('requests library not installed. Install with: pip install requests')
442
+
443
+ if session is None:
444
+ session = requests.Session()
445
+
446
+ # Store original hooks
447
+ original_hooks = session.hooks.get('response', [])
448
+
449
+ def inject_trace_context(r, *args, **kwargs):
450
+ """Hook to inject traceparent before sending request."""
451
+ try:
452
+ active_context = context.get_current()
453
+ propagate.inject(r.request.headers, active_context)
454
+ except Exception as e:
455
+ print(f'[Rebrandly OTEL] Failed to inject traceparent in requests hook: {str(e)}')
456
+ return r
457
+
458
+ # Add our hook while preserving existing hooks
459
+ session.hooks['response'] = original_hooks + [inject_trace_context]
460
+
461
+ return session
462
+
463
+
464
+ def httpx_with_tracing(client=None):
465
+ """
466
+ Create an httpx Client or enhance existing client with automatic traceparent injection.
467
+ Uses event hooks to inject traceparent header into all outgoing requests.
468
+
469
+ Args:
470
+ client: Optional httpx Client to enhance. If not provided, creates a new one.
471
+
472
+ Returns:
473
+ httpx.Client with traceparent injection hook
474
+
475
+ Example:
476
+ # Option 1: Create new client with tracing
477
+ client = httpx_with_tracing()
478
+ response = client.get('https://api.example.com/users')
479
+
480
+ # Option 2: Enhance existing client
481
+ import httpx
482
+ my_client = httpx.Client()
483
+ traced_client = httpx_with_tracing(my_client)
484
+ """
485
+ try:
486
+ import httpx
487
+ except ImportError:
488
+ raise ImportError('httpx library not installed. Install with: pip install httpx')
489
+
490
+ def inject_trace_context(request):
491
+ """Hook to inject traceparent before sending request."""
492
+ try:
493
+ active_context = context.get_current()
494
+ propagate.inject(request.headers, active_context)
495
+ except Exception as e:
496
+ print(f'[Rebrandly OTEL] Failed to inject traceparent in httpx hook: {str(e)}')
497
+
498
+ if client is None:
499
+ client = httpx.Client(event_hooks={'request': [inject_trace_context]})
500
+ else:
501
+ # Add our hook to existing event hooks
502
+ existing_hooks = client.event_hooks.get('request', [])
503
+ client.event_hooks['request'] = existing_hooks + [inject_trace_context]
504
+
505
+ return client
rebrandly_otel/logs.py ADDED
@@ -0,0 +1,154 @@
1
+ # logs.py
2
+ """Logging implementation for Rebrandly OTEL SDK."""
3
+ import logging
4
+ import sys
5
+ from typing import Optional
6
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
7
+ from opentelemetry.sdk._logs.export import (
8
+ BatchLogRecordProcessor,
9
+ ConsoleLogExporter,
10
+ SimpleLogRecordProcessor
11
+ )
12
+ from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
13
+ from opentelemetry._logs import set_logger_provider
14
+
15
+ from .otel_utils import *
16
+
17
+
18
+ class RebrandlyLogger:
19
+ """Wrapper for OpenTelemetry logging with Rebrandly-specific features."""
20
+
21
+ # Expose logging levels for convenience (compatible with standard logging)
22
+ DEBUG = logging.DEBUG
23
+ INFO = logging.INFO
24
+ WARNING = logging.WARNING
25
+ ERROR = logging.ERROR
26
+ CRITICAL = logging.CRITICAL
27
+ NOTSET = logging.NOTSET
28
+
29
+ def __init__(self):
30
+ self._logger: Optional[logging.Logger] = None
31
+ self._provider: Optional[LoggerProvider] = None
32
+ self._setup_logging()
33
+
34
+ def _setup_logging(self):
35
+ """Initialize logging with configured exporters."""
36
+
37
+ # Create provider with resource
38
+ self._provider = LoggerProvider(resource=create_resource())
39
+
40
+ # Add console exporter for local debugging
41
+ if is_otel_debug():
42
+ console_exporter = ConsoleLogExporter()
43
+ self._provider.add_log_record_processor(SimpleLogRecordProcessor(console_exporter))
44
+
45
+ # Add OTLP exporter if configured
46
+ otel_endpoint = get_otlp_endpoint()
47
+ if otel_endpoint:
48
+ otlp_exporter = OTLPLogExporter(
49
+ timeout=5,
50
+ endpoint=otel_endpoint
51
+ )
52
+ batch_processor = BatchLogRecordProcessor(otlp_exporter, export_timeout_millis=get_millis_batch_time())
53
+ self._provider.add_log_record_processor(batch_processor)
54
+
55
+ set_logger_provider(self._provider)
56
+
57
+ # Configure standard logging
58
+ self._configure_standard_logging()
59
+
60
+ def _configure_standard_logging(self):
61
+ """Configure standard Python logging with OTEL handler."""
62
+ # Get root logger
63
+ root_logger = logging.getLogger()
64
+
65
+ # Only configure basic logging if no handlers exist (not in Lambda)
66
+ if not root_logger.handlers:
67
+ logging.basicConfig(
68
+ level=logging.INFO,
69
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
70
+ stream=sys.stdout
71
+ )
72
+
73
+ # Add OTEL handler without removing existing handlers
74
+ otel_handler = LoggingHandler(logger_provider=self._provider)
75
+ otel_handler.setLevel(logging.INFO)
76
+
77
+ # Add filter to prevent OpenTelemetry's internal logs from being captured
78
+ # This prevents infinite recursion when OTEL tries to log warnings
79
+ otel_handler.addFilter(lambda record: not record.name.startswith('opentelemetry'))
80
+
81
+ root_logger.addHandler(otel_handler)
82
+
83
+ # Create service-specific logger
84
+ self._logger = logging.getLogger(get_service_name())
85
+
86
+
87
+ @property
88
+ def logger(self) -> logging.Logger:
89
+ """Get the standard Python logger."""
90
+ if not self._logger:
91
+ self._logger = logging.getLogger(get_service_name())
92
+ return self._logger
93
+
94
+ def getLogger(self) -> logging.Logger:
95
+ """
96
+ Get the internal logger instance.
97
+ Alias for the logger property for consistency with standard logging API.
98
+ """
99
+ return self.logger
100
+
101
+ def setLevel(self, level: int):
102
+ """
103
+ Set the logging level for both the internal logger and OTEL handler.
104
+
105
+ Args:
106
+ level: Logging level (e.g., logging.INFO, logging.DEBUG, logging.WARNING)
107
+ """
108
+ # Set level on the service-specific logger using the original unbound method
109
+ # This avoids infinite recursion if the logger's setLevel has been monkey-patched
110
+ if self._logger:
111
+ logging.Logger.setLevel(self._logger, level)
112
+
113
+ # Set level on the OTEL handler
114
+ root_logger = logging.getLogger()
115
+ for handler in root_logger.handlers:
116
+ if isinstance(handler, LoggingHandler):
117
+ handler.setLevel(level)
118
+
119
+ def force_flush(self, timeout_millis: int = 5000) -> bool:
120
+ """
121
+ Force flush all pending logs.
122
+
123
+ Args:
124
+ timeout_millis: Maximum time to wait for flush in milliseconds
125
+
126
+ Returns:
127
+ True if flush succeeded, False otherwise
128
+ """
129
+ if not self._provider:
130
+ return True
131
+
132
+ try:
133
+ # Force flush the logger provider
134
+ success = self._provider.force_flush(timeout_millis)
135
+
136
+ # Also flush Python's logging handlers
137
+ if self._logger:
138
+ for handler in self._logger.handlers:
139
+ if hasattr(handler, 'flush'):
140
+ handler.flush()
141
+
142
+ return success
143
+ except Exception as e:
144
+ print(f"[Logger] Error during force flush: {e}")
145
+ return False
146
+
147
+ def shutdown(self):
148
+ """Shutdown the logger provider."""
149
+ if self._provider:
150
+ try:
151
+ self._provider.shutdown()
152
+ print("[Logger] Shutdown completed")
153
+ except Exception as e:
154
+ print(f"[Logger] Error during shutdown: {e}")