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.
- rebrandly_otel/__init__.py +32 -0
- rebrandly_otel/api_gateway_utils.py +230 -0
- rebrandly_otel/fastapi_support.py +278 -0
- rebrandly_otel/flask_support.py +222 -0
- rebrandly_otel/http_constants.py +38 -0
- rebrandly_otel/http_utils.py +505 -0
- rebrandly_otel/logs.py +154 -0
- rebrandly_otel/metrics.py +212 -0
- rebrandly_otel/otel_utils.py +169 -0
- rebrandly_otel/pymysql_instrumentation.py +219 -0
- rebrandly_otel/rebrandly_otel.py +614 -0
- rebrandly_otel/span_attributes_processor.py +106 -0
- rebrandly_otel/traces.py +198 -0
- rebrandly_otel-0.3.1.dist-info/METADATA +1926 -0
- rebrandly_otel-0.3.1.dist-info/RECORD +18 -0
- rebrandly_otel-0.3.1.dist-info/WHEEL +5 -0
- rebrandly_otel-0.3.1.dist-info/licenses/LICENSE +19 -0
- rebrandly_otel-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -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}")
|