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,222 @@
1
+ # flask_integration.py
2
+ """Flask integration for Rebrandly OTEL SDK."""
3
+
4
+ import json
5
+ from opentelemetry.trace import Status, StatusCode, SpanKind
6
+ from .http_utils import filter_important_headers, capture_request_body, auto_detect_route_pattern
7
+ from .http_constants import (
8
+ HTTP_REQUEST_METHOD,
9
+ HTTP_REQUEST_HEADERS,
10
+ HTTP_REQUEST_HEADER_TRACEPARENT,
11
+ HTTP_REQUEST_BODY,
12
+ HTTP_RESPONSE_STATUS_CODE,
13
+ HTTP_ROUTE,
14
+ URL_FULL,
15
+ URL_SCHEME,
16
+ URL_PATH,
17
+ URL_QUERY,
18
+ USER_AGENT_ORIGINAL,
19
+ NETWORK_PROTOCOL_VERSION,
20
+ SERVER_ADDRESS,
21
+ SERVER_PORT,
22
+ CLIENT_ADDRESS,
23
+ ERROR_TYPE
24
+ )
25
+ from .api_gateway_utils import extract_api_gateway_authorizer_attributes
26
+
27
+ from flask import request, jsonify, g
28
+ from werkzeug.exceptions import HTTPException
29
+
30
+
31
+ def setup_flask(otel, app):
32
+ """
33
+ Setup Flask application with OTEL instrumentation.
34
+
35
+ Example:
36
+ from flask import Flask
37
+ from rebrandly_otel import otel
38
+ from rebrandly_otel.flask_integration import setup_flask
39
+
40
+ app = Flask(__name__)
41
+ setup_flask(otel, app)
42
+ """
43
+ app.before_request(lambda: app_before_request(otel))
44
+ app.after_request(lambda response: app_after_request(otel, response))
45
+ app.register_error_handler(Exception, lambda e: flask_error_handler(otel, e))
46
+ return app
47
+
48
+ def app_before_request(otel):
49
+ """
50
+ Setup tracing for incoming Flask request.
51
+ To be used with Flask's before_request hook.
52
+ """
53
+
54
+ # Extract trace context from headers
55
+ headers = dict(request.headers)
56
+ token = otel.attach_context(headers)
57
+ request.trace_token = token
58
+
59
+ # Initial span name - will be updated with route pattern in after_request
60
+ # Using just method initially since route is not available before routing completes
61
+ span_name = f"{request.method}"
62
+
63
+ # Filter headers to keep only important ones
64
+ filtered_headers = filter_important_headers(headers)
65
+
66
+ # Capture request body if available (before span creation)
67
+ request_body = None
68
+ try:
69
+ content_type = request.headers.get('Content-Type', '')
70
+ # Try to get JSON body first, fallback to raw data
71
+ body = request.get_json(silent=True) or request.data
72
+ request_body = capture_request_body(body, content_type)
73
+ except Exception:
74
+ # Silently skip body capture if it fails
75
+ pass
76
+
77
+ # Build attributes dict, excluding None values
78
+ # Note: HTTP_ROUTE will be set in after_request when route pattern is available
79
+ attributes = {
80
+ # Required HTTP attributes per semantic conventions
81
+ HTTP_REQUEST_METHOD: request.method,
82
+ HTTP_REQUEST_HEADERS: json.dumps(filtered_headers, default=str),
83
+ HTTP_REQUEST_HEADER_TRACEPARENT: headers.get('traceparent'),
84
+ HTTP_REQUEST_BODY: request_body,
85
+ URL_FULL: request.url,
86
+ URL_SCHEME: request.scheme,
87
+ URL_PATH: request.path,
88
+ # HTTP_ROUTE will be set in after_request after routing completes
89
+ NETWORK_PROTOCOL_VERSION: request.environ.get('SERVER_PROTOCOL', 'HTTP/1.1').split('/')[-1],
90
+ SERVER_ADDRESS: request.host.split(':')[0],
91
+ SERVER_PORT: request.host.split(':')[1] if ':' in request.host else (443 if request.scheme == 'https' else 80),
92
+ }
93
+
94
+ # Add optional attributes only if they have values
95
+ if request.query_string:
96
+ attributes[URL_QUERY] = request.query_string.decode('utf-8')
97
+
98
+ if request.user_agent and request.user_agent.string:
99
+ attributes[USER_AGENT_ORIGINAL] = request.user_agent.string
100
+
101
+ if request.remote_addr:
102
+ attributes[CLIENT_ADDRESS] = request.remote_addr
103
+
104
+ # Extract authorizer context if available (when running behind API Gateway via Lambda)
105
+ # The Lambda handler should store the event in flask.g.lambda_event
106
+ if hasattr(g, 'lambda_event') and g.lambda_event:
107
+ authorizer_attrs = extract_api_gateway_authorizer_attributes(g.lambda_event)
108
+ attributes.update(authorizer_attrs)
109
+
110
+ # Start span for request using start_as_current_span to make it the active span
111
+ span = otel.tracer.tracer.start_as_current_span(
112
+ span_name,
113
+ attributes=attributes,
114
+ kind=SpanKind.SERVER
115
+ )
116
+ # Store both the span context manager and the span itself
117
+ request.span_context = span
118
+ request.span = span.__enter__() # This activates the span and returns the span object
119
+
120
+ # Log request start
121
+ otel.logger.logger.info(f"Request started: {request.method} {request.path}",
122
+ extra={"http.method": request.method, "http.path": request.path})
123
+
124
+ def app_after_request(otel, response):
125
+ """
126
+ Cleanup tracing after Flask request completes.
127
+ To be used with Flask's after_request hook.
128
+ """
129
+
130
+ # Check if we have a span and it's still recording
131
+ if hasattr(request, 'span') and request.span.is_recording():
132
+ # Extract route template from Flask's url_rule (available after routing)
133
+ # This is critical for OpenTelemetry compliance - http.route must be low cardinality
134
+ route_pattern = None
135
+ if hasattr(request, 'url_rule') and request.url_rule is not None:
136
+ # Flask's url_rule.rule contains the route template (e.g., '/users/<user_id>')
137
+ route_pattern = request.url_rule.rule
138
+ else:
139
+ # Fallback: auto-detect pattern from path using heuristics
140
+ route_pattern = auto_detect_route_pattern(request.path)
141
+
142
+ # Update span name with route pattern (REQUIRED by OTEL spec)
143
+ # Per spec: span name should be "{method} {http.route}" for low cardinality
144
+ if route_pattern:
145
+ request.span.update_name(f"{request.method} {route_pattern}")
146
+ request.span.set_attribute(HTTP_ROUTE, route_pattern)
147
+
148
+ # Update response status code
149
+ request.span.set_attribute(HTTP_RESPONSE_STATUS_CODE, response.status_code)
150
+
151
+ # Set span status based on HTTP status code following OpenTelemetry semantic conventions
152
+ # For SERVER spans: only 5xx codes are marked as ERROR, all others left UNSET
153
+ # Per spec: https://opentelemetry.io/docs/specs/semconv/http/http-spans/
154
+ if response.status_code >= 500:
155
+ request.span.set_status(Status(StatusCode.ERROR))
156
+ # For all other codes (1xx, 2xx, 3xx, 4xx), leave status unset
157
+
158
+ # Properly close the span context manager
159
+ if hasattr(request, 'span_context'):
160
+ request.span_context.__exit__(None, None, None)
161
+ else:
162
+ # Fallback if we don't have the context manager
163
+ request.span.end()
164
+
165
+ # Detach context
166
+ if hasattr(request, 'trace_token'):
167
+ otel.detach_context(request.trace_token)
168
+
169
+ # Log request completion
170
+ otel.logger.logger.info(f"Request completed: {response.status_code}",
171
+ extra={"http.response.status_code": response.status_code})
172
+
173
+ otel.force_flush(timeout_millis=100)
174
+ return response
175
+
176
+ def flask_error_handler(otel, exception):
177
+ """
178
+ Handle Flask exceptions and record them in the current span.
179
+ To be used with Flask's errorhandler decorator.
180
+ """
181
+
182
+ # Determine the status code
183
+ if isinstance(exception, HTTPException):
184
+ status_code = exception.code
185
+ elif hasattr(exception, 'status_code'):
186
+ status_code = exception.status_code
187
+ elif hasattr(exception, 'code'):
188
+ status_code = exception.code if isinstance(exception.code, int) else 500
189
+ else:
190
+ status_code = 500
191
+
192
+ # Record exception in span if available and still recording
193
+ if hasattr(request, 'span') and request.span.is_recording():
194
+ request.span.set_attribute(HTTP_RESPONSE_STATUS_CODE, status_code)
195
+ request.span.set_attribute(ERROR_TYPE, type(exception).__name__)
196
+
197
+ request.span.record_exception(exception)
198
+ request.span.set_status(Status(StatusCode.ERROR, str(exception)))
199
+ request.span.add_event("exception", {
200
+ "exception.type": type(exception).__name__,
201
+ "exception.message": str(exception)
202
+ })
203
+
204
+ # Only close the span if it's still recording (not already ended)
205
+ if hasattr(request, 'span_context'):
206
+ request.span_context.__exit__(type(exception), exception, None)
207
+ else:
208
+ request.span.end()
209
+
210
+ # Log the error with status code
211
+ otel.logger.logger.error(f"Unhandled exception: {exception} (status: {status_code})",
212
+ exc_info=True,
213
+ extra={
214
+ "exception.type": type(exception).__name__,
215
+ "http.response.status_code": status_code
216
+ })
217
+
218
+ # Return error response with the determined status code
219
+ return jsonify({
220
+ "error": str(exception),
221
+ "type": type(exception).__name__
222
+ }), status_code
@@ -0,0 +1,38 @@
1
+ """HTTP semantic attribute constants for OpenTelemetry instrumentation."""
2
+
3
+ # HTTP Semantic Attributes following OpenTelemetry conventions
4
+ # https://opentelemetry.io/docs/specs/semconv/http/http-spans/
5
+
6
+ HTTP_REQUEST_METHOD = "http.request.method"
7
+ HTTP_REQUEST_HEADERS = "http.request.headers"
8
+ HTTP_REQUEST_HEADER_TRACEPARENT = "http.request.header.traceparent"
9
+ HTTP_REQUEST_BODY = "http.request.body.content"
10
+ HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"
11
+ HTTP_ROUTE = "http.route"
12
+
13
+ # URL attributes
14
+ URL_FULL = "url.full"
15
+ URL_SCHEME = "url.scheme"
16
+ URL_PATH = "url.path"
17
+ URL_QUERY = "url.query"
18
+
19
+ # Network attributes
20
+ NETWORK_PROTOCOL_VERSION = "network.protocol.version"
21
+
22
+ # Server attributes
23
+ SERVER_ADDRESS = "server.address"
24
+ SERVER_PORT = "server.port"
25
+
26
+ # Client attributes
27
+ CLIENT_ADDRESS = "client.address"
28
+
29
+ # User agent
30
+ USER_AGENT_ORIGINAL = "user_agent.original"
31
+
32
+ # Error attributes
33
+ ERROR_TYPE = "error.type"
34
+
35
+ # User and organization attributes (Rebrandly-specific)
36
+ USER_ID = "user.id"
37
+ REBRANDLY_WORKSPACE = "rebrandly.workspace"
38
+ REBRANDLY_ORGANIZATION = "rebrandly.organization"