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,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
|