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,32 @@
1
+ # src/__init__.py
2
+ from .rebrandly_otel import *
3
+ from .flask_support import setup_flask
4
+ from .fastapi_support import setup_fastapi
5
+ from .pymysql_instrumentation import instrument_pymysql
6
+ from .api_gateway_utils import (
7
+ is_api_gateway_event,
8
+ extract_api_gateway_http_attributes,
9
+ extract_api_gateway_context
10
+ )
11
+
12
+ # Explicitly define what's available
13
+ __all__ = [
14
+ 'otel',
15
+ 'lambda_handler',
16
+ 'span',
17
+ 'aws_message_span',
18
+ 'traces',
19
+ 'tracer',
20
+ 'metrics',
21
+ 'logger',
22
+ 'force_flush',
23
+ 'aws_message_handler',
24
+ 'shutdown',
25
+ 'setup_flask',
26
+ 'setup_fastapi',
27
+ 'instrument_pymysql',
28
+ # API Gateway utilities
29
+ 'is_api_gateway_event',
30
+ 'extract_api_gateway_http_attributes',
31
+ 'extract_api_gateway_context'
32
+ ]
@@ -0,0 +1,230 @@
1
+ """
2
+ API Gateway instrumentation utilities for Lambda handlers.
3
+ Extracts HTTP semantic attributes from API Gateway events (REST API v1 and HTTP API v2).
4
+ """
5
+
6
+ import json
7
+ import re
8
+ from typing import Any, Dict, Optional, Tuple
9
+ from urllib.parse import urlencode
10
+
11
+ from .http_utils import (
12
+ filter_important_headers,
13
+ capture_request_body,
14
+ auto_detect_route_pattern
15
+ )
16
+ from .http_constants import (
17
+ HTTP_REQUEST_METHOD, HTTP_REQUEST_HEADERS, HTTP_REQUEST_HEADER_TRACEPARENT,
18
+ HTTP_REQUEST_BODY, HTTP_ROUTE, URL_FULL, URL_SCHEME,
19
+ URL_PATH, URL_QUERY, USER_AGENT_ORIGINAL, SERVER_ADDRESS, SERVER_PORT,
20
+ CLIENT_ADDRESS, NETWORK_PROTOCOL_VERSION,
21
+ USER_ID, REBRANDLY_WORKSPACE, REBRANDLY_ORGANIZATION
22
+ )
23
+
24
+
25
+ def is_api_gateway_event(event: Any) -> bool:
26
+ """
27
+ Check if the event is from API Gateway (REST v1 or HTTP v2).
28
+
29
+ Args:
30
+ event: Lambda event object
31
+
32
+ Returns:
33
+ True if the event is from API Gateway
34
+ """
35
+ if not isinstance(event, dict):
36
+ return False
37
+ return bool(
38
+ event.get('httpMethod') or
39
+ event.get('requestContext', {}).get('http', {}).get('method')
40
+ )
41
+
42
+
43
+ def _is_valid_authorizer_value(value: Any) -> bool:
44
+ """
45
+ Check if a value is meaningful (not null/None/"None"/empty).
46
+ Per OpenTelemetry conventions, attributes should only be set when they have meaningful values.
47
+ """
48
+ return value is not None and value != 'None' and value != 'null' and value != ''
49
+
50
+
51
+ def extract_api_gateway_authorizer_attributes(event: Dict[str, Any]) -> Dict[str, Any]:
52
+ """
53
+ Extract user and workspace context from API Gateway requestContext.authorizer.
54
+ Only sets attributes when values are present (per OpenTelemetry conventions).
55
+
56
+ Args:
57
+ event: API Gateway event object
58
+
59
+ Returns:
60
+ Dict with user.id, rebrandly.workspace, rebrandly.organization attributes (only if present)
61
+ """
62
+ attrs = {}
63
+ if not event or not isinstance(event, dict):
64
+ return attrs
65
+ authorizer = event.get('requestContext', {}).get('authorizer', {})
66
+
67
+ if not authorizer:
68
+ return attrs
69
+
70
+ user_id = authorizer.get('id')
71
+ if _is_valid_authorizer_value(user_id):
72
+ attrs[USER_ID] = str(user_id)
73
+
74
+ workspace = authorizer.get('workspace')
75
+ if _is_valid_authorizer_value(workspace):
76
+ attrs[REBRANDLY_WORKSPACE] = str(workspace)
77
+
78
+ organization = authorizer.get('organization')
79
+ if _is_valid_authorizer_value(organization):
80
+ attrs[REBRANDLY_ORGANIZATION] = str(organization)
81
+
82
+ return attrs
83
+
84
+
85
+ def extract_api_gateway_http_attributes(event: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[str]]:
86
+ """
87
+ Extract HTTP semantic attributes from API Gateway event.
88
+ Supports both REST API (v1) and HTTP API (v2) formats.
89
+
90
+ Args:
91
+ event: API Gateway event object
92
+
93
+ Returns:
94
+ tuple: (attributes_dict, span_name)
95
+ """
96
+ attrs = {}
97
+
98
+ # Detect API Gateway v1 (REST) or v2 (HTTP API)
99
+ http_method = event.get('httpMethod') or (
100
+ event.get('requestContext', {}).get('http', {}).get('method')
101
+ )
102
+ if not http_method:
103
+ return attrs, None
104
+
105
+ # Extract path
106
+ path = event.get('path') or event.get('rawPath') or (
107
+ event.get('requestContext', {}).get('http', {}).get('path')
108
+ )
109
+
110
+ # Get request context
111
+ req_ctx = event.get('requestContext', {})
112
+ headers = event.get('headers') or {}
113
+
114
+ # Determine route pattern
115
+ if event.get('resource'):
116
+ route_pattern = event.get('resource')
117
+ elif event.get('routeKey'):
118
+ # HTTP API format (e.g., "GET /users/{id}") - strip the method prefix
119
+ route_pattern = re.sub(r'^[A-Z]+\s+', '', event.get('routeKey'))
120
+ else:
121
+ route_pattern = auto_detect_route_pattern(path) if path else '/'
122
+
123
+ # Build query string
124
+ query_params = event.get('queryStringParameters')
125
+ query_string = ''
126
+ if query_params:
127
+ query_string = urlencode(query_params)
128
+
129
+ # Extract server details
130
+ domain_name = req_ctx.get('domainName') or headers.get('host') or headers.get('Host')
131
+ protocol = req_ctx.get('protocol') or req_ctx.get('http', {}).get('protocol') or 'HTTP/1.1'
132
+
133
+ # Extract client IP (different locations for REST vs HTTP API)
134
+ client_ip = req_ctx.get('identity', {}).get('sourceIp') or req_ctx.get('http', {}).get('sourceIp')
135
+
136
+ # Extract user agent (case-insensitive header lookup)
137
+ user_agent = headers.get('user-agent') or headers.get('User-Agent')
138
+
139
+ # Set HTTP semantic attributes
140
+ attrs[HTTP_REQUEST_METHOD] = http_method
141
+ attrs[HTTP_ROUTE] = route_pattern
142
+ attrs[URL_PATH] = path
143
+ attrs[URL_SCHEME] = 'https'
144
+
145
+ if query_string:
146
+ attrs[URL_QUERY] = query_string
147
+
148
+ if domain_name:
149
+ attrs[SERVER_ADDRESS] = domain_name
150
+ attrs[SERVER_PORT] = 443
151
+ full_url = f"https://{domain_name}{path}"
152
+ if query_string:
153
+ full_url += f"?{query_string}"
154
+ attrs[URL_FULL] = full_url
155
+
156
+ if client_ip:
157
+ attrs[CLIENT_ADDRESS] = client_ip
158
+
159
+ if user_agent:
160
+ attrs[USER_AGENT_ORIGINAL] = user_agent
161
+
162
+ if protocol:
163
+ attrs[NETWORK_PROTOCOL_VERSION] = protocol
164
+
165
+ # Capture filtered headers (exclude sensitive ones)
166
+ if headers:
167
+ filtered_headers = filter_important_headers(headers)
168
+ if filtered_headers:
169
+ attrs[HTTP_REQUEST_HEADERS] = json.dumps(filtered_headers)
170
+ # Store traceparent for debugging/correlation
171
+ traceparent = headers.get('traceparent') or headers.get('Traceparent')
172
+ if traceparent:
173
+ attrs[HTTP_REQUEST_HEADER_TRACEPARENT] = traceparent
174
+
175
+ # Capture request body for POST/PUT/PATCH requests (with redaction)
176
+ body = event.get('body')
177
+ if body and http_method in ['POST', 'PUT', 'PATCH']:
178
+ content_type = headers.get('content-type') or headers.get('Content-Type')
179
+ captured_body = capture_request_body(body, content_type)
180
+ if captured_body:
181
+ attrs[HTTP_REQUEST_BODY] = captured_body
182
+
183
+ # Add request context ID for correlation
184
+ if req_ctx.get('requestId'):
185
+ attrs['http.request_id'] = req_ctx.get('requestId')
186
+
187
+ # Extract authorizer context (user, workspace, organization)
188
+ authorizer_attrs = extract_api_gateway_authorizer_attributes(event)
189
+ attrs.update(authorizer_attrs)
190
+
191
+ # Keep legacy attributes for backward compatibility
192
+ attrs['http.method'] = http_method
193
+ attrs['http.target'] = path + (f"?{query_string}" if query_string else '')
194
+ if domain_name:
195
+ attrs['http.scheme'] = 'https'
196
+ attrs['http.url'] = f"https://{domain_name}{path}" + (f"?{query_string}" if query_string else '')
197
+
198
+ # Build span name
199
+ span_name = f"{http_method} {route_pattern}"
200
+
201
+ return attrs, span_name
202
+
203
+
204
+ def extract_api_gateway_context(event: Dict[str, Any]) -> Optional[Dict[str, str]]:
205
+ """
206
+ Extract trace context carrier from API Gateway headers.
207
+ Returns a dict with traceparent/tracestate if present.
208
+
209
+ Args:
210
+ event: API Gateway event object
211
+
212
+ Returns:
213
+ Dict with traceparent/tracestate if present, None otherwise
214
+ """
215
+ if not event or not isinstance(event, dict):
216
+ return None
217
+ headers = event.get('headers')
218
+ if not headers:
219
+ return None
220
+
221
+ # Normalize header keys to lowercase
222
+ normalized = {k.lower(): v for k, v in headers.items()}
223
+
224
+ carrier = {}
225
+ if 'traceparent' in normalized:
226
+ carrier['traceparent'] = normalized['traceparent']
227
+ if 'tracestate' in normalized:
228
+ carrier['tracestate'] = normalized['tracestate']
229
+
230
+ return carrier if carrier else None
@@ -0,0 +1,278 @@
1
+ # fastapi_integration.py
2
+ """FastAPI integration for Rebrandly OTEL SDK."""
3
+ import json
4
+ from opentelemetry.trace import Status, StatusCode, SpanKind
5
+ from .http_utils import filter_important_headers, capture_request_body, auto_detect_route_pattern
6
+ from .http_constants import (
7
+ HTTP_REQUEST_METHOD,
8
+ HTTP_REQUEST_HEADERS,
9
+ HTTP_REQUEST_HEADER_TRACEPARENT,
10
+ HTTP_REQUEST_BODY,
11
+ HTTP_RESPONSE_STATUS_CODE,
12
+ HTTP_ROUTE,
13
+ URL_FULL,
14
+ URL_SCHEME,
15
+ URL_PATH,
16
+ URL_QUERY,
17
+ USER_AGENT_ORIGINAL,
18
+ NETWORK_PROTOCOL_VERSION,
19
+ SERVER_ADDRESS,
20
+ SERVER_PORT,
21
+ CLIENT_ADDRESS,
22
+ ERROR_TYPE
23
+ )
24
+ from .api_gateway_utils import extract_api_gateway_authorizer_attributes
25
+ from fastapi import HTTPException, Depends
26
+ from starlette.requests import Request
27
+ from starlette.middleware.base import BaseHTTPMiddleware
28
+ from fastapi.responses import JSONResponse
29
+
30
+ import time
31
+
32
+ def setup_fastapi(otel , app):
33
+ """
34
+ Setup FastAPI application with OTEL instrumentation.
35
+
36
+ Example:
37
+ from fastapi import FastAPI
38
+ from rebrandly_otel import otel
39
+ from rebrandly_otel.fastapi_integration import setup_fastapi
40
+
41
+ app = FastAPI()
42
+ setup_fastapi(otel, app)
43
+ """
44
+
45
+ # Add middleware
46
+ add_otel_middleware(otel, app)
47
+
48
+ # Add exception handlers
49
+ app.add_exception_handler(HTTPException, lambda request, exc: fastapi_exception_handler(otel, request, exc))
50
+ app.add_exception_handler(Exception, lambda request, exc: fastapi_exception_handler(otel, request, exc))
51
+
52
+ return app
53
+
54
+ def add_otel_middleware(otel, app):
55
+ """
56
+ Add OTEL middleware to FastAPI application.
57
+ """
58
+
59
+ class OTELMiddleware(BaseHTTPMiddleware):
60
+ def __init__(self, app):
61
+ super().__init__(app)
62
+ self.otel = otel
63
+
64
+ async def dispatch(self, request: Request, call_next):
65
+ # Extract trace context from headers
66
+ headers = dict(request.headers)
67
+ token = self.otel.attach_context(headers)
68
+
69
+ # Initial span name - will be updated with route pattern after routing completes
70
+ # Using just method initially for low cardinality
71
+ span_name = f"{request.method}"
72
+
73
+ # Filter headers to keep only important ones
74
+ filtered_headers = filter_important_headers(headers)
75
+
76
+ # Capture request body if available (before span creation)
77
+ request_body = None
78
+ try:
79
+ content_type = request.headers.get('content-type', '')
80
+ # Read body (this caches it so FastAPI can still access it later)
81
+ body_bytes = await request.body()
82
+ # Try to parse as JSON, fallback to raw bytes
83
+ try:
84
+ body = json.loads(body_bytes.decode('utf-8'))
85
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
86
+ body = body_bytes
87
+
88
+ request_body = capture_request_body(body, content_type)
89
+ except Exception:
90
+ # Silently skip body capture if it fails
91
+ pass
92
+
93
+ # Build attributes dict, excluding None values
94
+ attributes = {
95
+ # Required HTTP attributes per semantic conventions
96
+ HTTP_REQUEST_METHOD: request.method,
97
+ HTTP_REQUEST_HEADERS: json.dumps(filtered_headers, default=str),
98
+ HTTP_REQUEST_HEADER_TRACEPARENT: headers.get('traceparent'),
99
+ HTTP_REQUEST_BODY: request_body,
100
+ URL_FULL: str(request.url),
101
+ URL_SCHEME: request.url.scheme,
102
+ URL_PATH: request.url.path,
103
+ NETWORK_PROTOCOL_VERSION: "1.1", # FastAPI/Starlette typically uses HTTP/1.1
104
+ SERVER_ADDRESS: request.url.hostname,
105
+ SERVER_PORT: request.url.port or (443 if request.url.scheme == 'https' else 80),
106
+ }
107
+
108
+ # Add optional attributes only if they have values
109
+ if request.url.query:
110
+ attributes[URL_QUERY] = request.url.query
111
+
112
+ user_agent = request.headers.get("user-agent")
113
+ if user_agent:
114
+ attributes[USER_AGENT_ORIGINAL] = user_agent
115
+
116
+ if request.client and request.client.host:
117
+ attributes[CLIENT_ADDRESS] = request.client.host
118
+
119
+ # Extract authorizer context if available (when running behind API Gateway via Lambda)
120
+ # The Lambda handler should store the event in request.state.lambda_event
121
+ if hasattr(request.state, 'lambda_event') and request.state.lambda_event:
122
+ authorizer_attrs = extract_api_gateway_authorizer_attributes(request.state.lambda_event)
123
+ attributes.update(authorizer_attrs)
124
+
125
+ # Use start_as_current_span for proper context propagation
126
+ with self.otel.tracer.tracer.start_as_current_span(
127
+ span_name,
128
+ attributes=attributes,
129
+ kind=SpanKind.SERVER
130
+ ) as span:
131
+ # Log request start
132
+ self.otel.logger.logger.info(f"Request started: {request.method} {request.url.path}",
133
+ extra={"http.method": request.method, "http.path": request.url.path})
134
+
135
+ # Store span in request state for access in routes
136
+ request.state.span = span
137
+ request.state.trace_token = token
138
+
139
+ start_time = time.time()
140
+
141
+ try:
142
+ # Process request
143
+ response = await call_next(request)
144
+
145
+ # After routing completes, extract the route template
146
+ # This is critical for OpenTelemetry compliance - http.route must be low cardinality
147
+ route_pattern = None
148
+
149
+ # Method 1: Get route from matched endpoint in scope
150
+ # FastAPI/Starlette stores the matched route after routing completes
151
+ if 'endpoint' in request.scope and 'router' in request.scope:
152
+ endpoint = request.scope['endpoint']
153
+ router = request.scope['router']
154
+
155
+ # Find the route that matches this endpoint
156
+ if hasattr(router, 'routes'):
157
+ for route in router.routes:
158
+ if hasattr(route, 'endpoint') and route.endpoint == endpoint:
159
+ if hasattr(route, 'path'):
160
+ route_pattern = route.path # e.g., '/items/{item_id}'
161
+ break
162
+
163
+ # Method 2: Fallback - auto-detect pattern from path using heuristics
164
+ if not route_pattern:
165
+ route_pattern = auto_detect_route_pattern(request.url.path)
166
+
167
+ # Update span with route pattern (REQUIRED by OTEL spec)
168
+ # Per spec: span name should be "{method} {http.route}" for low cardinality
169
+ if route_pattern:
170
+ span.update_name(f"{request.method} {route_pattern}")
171
+ span.set_attribute(HTTP_ROUTE, route_pattern)
172
+
173
+ # Set response attributes using semantic conventions
174
+ span.set_attribute(HTTP_RESPONSE_STATUS_CODE, response.status_code)
175
+
176
+ # Set span status based on HTTP status code following OpenTelemetry semantic conventions
177
+ # For SERVER spans: only 5xx codes are marked as ERROR, all others left UNSET
178
+ # Per spec: https://opentelemetry.io/docs/specs/semconv/http/http-spans/
179
+ if response.status_code >= 500:
180
+ span.set_status(Status(StatusCode.ERROR))
181
+ # For all other codes (1xx, 2xx, 3xx, 4xx), leave status unset
182
+
183
+ # Log request completion
184
+ self.otel.logger.logger.info(f"Request completed: {response.status_code}",
185
+ extra={"http.response.status_code": response.status_code})
186
+ otel.force_flush(timeout_millis=100)
187
+ return response
188
+
189
+ except Exception as e:
190
+ # Record exception
191
+ span.record_exception(e)
192
+ span.set_status(Status(StatusCode.ERROR, str(e)))
193
+ span.add_event("exception", {
194
+ "exception.type": type(e).__name__,
195
+ "exception.message": str(e)
196
+ })
197
+
198
+ # Log error
199
+ self.otel.logger.logger.error(f"Unhandled exception: {e}",
200
+ exc_info=True,
201
+ extra={"exception.type": type(e).__name__})
202
+
203
+ raise
204
+
205
+ finally:
206
+ # Detach context
207
+ self.otel.detach_context(token)
208
+
209
+ # Add middleware to app
210
+ app.add_middleware(OTELMiddleware)
211
+
212
+ def fastapi_exception_handler(otel, request, exc):
213
+ """
214
+ Handle FastAPI exceptions and record them in the current span.
215
+ """
216
+
217
+ # Determine the status code
218
+ if isinstance(exc, HTTPException):
219
+ status_code = exc.status_code
220
+ error_detail = exc.detail
221
+ elif hasattr(exc, 'status_code'):
222
+ status_code = exc.status_code
223
+ error_detail = str(exc)
224
+ elif hasattr(exc, 'code'):
225
+ status_code = exc.code if isinstance(exc.code, int) else 500
226
+ error_detail = str(exc)
227
+ else:
228
+ status_code = 500
229
+ error_detail = str(exc)
230
+
231
+ # Record exception in span if available and still recording
232
+ if hasattr(request.state, 'span') and request.state.span.is_recording():
233
+ # Update response status code and error type
234
+ request.state.span.set_attribute(HTTP_RESPONSE_STATUS_CODE, status_code)
235
+ request.state.span.set_attribute(ERROR_TYPE, type(exc).__name__)
236
+
237
+ request.state.span.record_exception(exc)
238
+ request.state.span.set_status(Status(StatusCode.ERROR, str(exc)))
239
+ request.state.span.add_event("exception", {
240
+ "exception.type": type(exc).__name__,
241
+ "exception.message": str(exc)
242
+ })
243
+
244
+ # Log the error
245
+ otel.logger.logger.error(f"Unhandled exception: {exc} (status: {status_code})",
246
+ exc_info=True,
247
+ extra={
248
+ "exception.type": type(exc).__name__,
249
+ "http.response.status_code": status_code
250
+ })
251
+
252
+ # Return error response
253
+ return JSONResponse(
254
+ status_code=status_code,
255
+ content={
256
+ "error": error_detail,
257
+ "type": type(exc).__name__
258
+ }
259
+ )
260
+
261
+ # Optional: Dependency injection helper for accessing the span in routes
262
+ def get_current_span(request: Request):
263
+ """
264
+ FastAPI dependency to get the current span in route handlers.
265
+
266
+ Example:
267
+ from fastapi import Depends
268
+ from rebrandly_otel.fastapi_integration import get_current_span
269
+
270
+ @app.get("/example")
271
+ async def example(span = Depends(get_current_span)):
272
+ if span:
273
+ span.add_event("custom_event", {"key": "value"})
274
+ return {"status": "ok"}
275
+ """
276
+ if hasattr(request.state, 'span'):
277
+ return request.state.span
278
+ return None