Coverage for /var/srv/projects/api.amasfac.comuna18.com/tmp/venv/lib/python3.9/site-packages/sentry_sdk/integrations/asgi.py: 15%
154 statements
« prev ^ index » next coverage.py v6.4.4, created at 2023-07-17 14:22 -0600
« prev ^ index » next coverage.py v6.4.4, created at 2023-07-17 14:22 -0600
1"""
2An ASGI middleware.
4Based on Tom Christie's `sentry-asgi <https://github.com/encode/sentry-asgi>`_.
5"""
7import asyncio
8import inspect
9import urllib
11from sentry_sdk._functools import partial
12from sentry_sdk._types import MYPY
13from sentry_sdk.hub import Hub, _should_send_default_pii
14from sentry_sdk.integrations._wsgi_common import _filter_headers
15from sentry_sdk.sessions import auto_session_tracking
16from sentry_sdk.tracing import (
17 SOURCE_FOR_STYLE,
18 TRANSACTION_SOURCE_ROUTE,
19 TRANSACTION_SOURCE_UNKNOWN,
20)
21from sentry_sdk.utils import (
22 ContextVar,
23 event_from_exception,
24 transaction_from_function,
25 HAS_REAL_CONTEXTVARS,
26 CONTEXTVARS_ERROR_MESSAGE,
27)
28from sentry_sdk.tracing import Transaction
30if MYPY: 30 ↛ 31line 30 didn't jump to line 31, because the condition on line 30 was never true
31 from typing import Dict
32 from typing import Any
33 from typing import Optional
34 from typing import Callable
36 from typing_extensions import Literal
38 from sentry_sdk._types import Event, Hint
41_asgi_middleware_applied = ContextVar("sentry_asgi_middleware_applied")
43_DEFAULT_TRANSACTION_NAME = "generic ASGI request"
45TRANSACTION_STYLE_VALUES = ("endpoint", "url")
48def _capture_exception(hub, exc):
49 # type: (Hub, Any) -> None
51 # Check client here as it might have been unset while streaming response
52 if hub.client is not None:
53 event, hint = event_from_exception(
54 exc,
55 client_options=hub.client.options,
56 mechanism={"type": "asgi", "handled": False},
57 )
58 hub.capture_event(event, hint=hint)
61def _looks_like_asgi3(app):
62 # type: (Any) -> bool
63 """
64 Try to figure out if an application object supports ASGI3.
66 This is how uvicorn figures out the application version as well.
67 """
68 if inspect.isclass(app):
69 return hasattr(app, "__await__")
70 elif inspect.isfunction(app):
71 return asyncio.iscoroutinefunction(app)
72 else:
73 call = getattr(app, "__call__", None) # noqa
74 return asyncio.iscoroutinefunction(call)
77class SentryAsgiMiddleware:
78 __slots__ = ("app", "__call__", "transaction_style")
80 def __init__(self, app, unsafe_context_data=False, transaction_style="endpoint"):
81 # type: (Any, bool, str) -> None
82 """
83 Instrument an ASGI application with Sentry. Provides HTTP/websocket
84 data to sent events and basic handling for exceptions bubbling up
85 through the middleware.
87 :param unsafe_context_data: Disable errors when a proper contextvars installation could not be found. We do not recommend changing this from the default.
88 """
90 if not unsafe_context_data and not HAS_REAL_CONTEXTVARS:
91 # We better have contextvars or we're going to leak state between
92 # requests.
93 raise RuntimeError(
94 "The ASGI middleware for Sentry requires Python 3.7+ "
95 "or the aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
96 )
97 if transaction_style not in TRANSACTION_STYLE_VALUES:
98 raise ValueError(
99 "Invalid value for transaction_style: %s (must be in %s)"
100 % (transaction_style, TRANSACTION_STYLE_VALUES)
101 )
102 self.transaction_style = transaction_style
103 self.app = app
105 if _looks_like_asgi3(app):
106 self.__call__ = self._run_asgi3 # type: Callable[..., Any]
107 else:
108 self.__call__ = self._run_asgi2
110 def _run_asgi2(self, scope):
111 # type: (Any) -> Any
112 async def inner(receive, send):
113 # type: (Any, Any) -> Any
114 return await self._run_app(scope, lambda: self.app(scope)(receive, send))
116 return inner
118 async def _run_asgi3(self, scope, receive, send):
119 # type: (Any, Any, Any) -> Any
120 return await self._run_app(scope, lambda: self.app(scope, receive, send))
122 async def _run_app(self, scope, callback):
123 # type: (Any, Any) -> Any
124 is_recursive_asgi_middleware = _asgi_middleware_applied.get(False)
126 if is_recursive_asgi_middleware:
127 try:
128 return await callback()
129 except Exception as exc:
130 _capture_exception(Hub.current, exc)
131 raise exc from None
133 _asgi_middleware_applied.set(True)
134 try:
135 hub = Hub(Hub.current)
136 with auto_session_tracking(hub, session_mode="request"):
137 with hub:
138 with hub.configure_scope() as sentry_scope:
139 sentry_scope.clear_breadcrumbs()
140 sentry_scope._name = "asgi"
141 processor = partial(self.event_processor, asgi_scope=scope)
142 sentry_scope.add_event_processor(processor)
144 ty = scope["type"]
146 if ty in ("http", "websocket"):
147 transaction = Transaction.continue_from_headers(
148 self._get_headers(scope),
149 op="{}.server".format(ty),
150 )
151 else:
152 transaction = Transaction(op="asgi.server")
154 transaction.name = _DEFAULT_TRANSACTION_NAME
155 transaction.source = TRANSACTION_SOURCE_ROUTE
156 transaction.set_tag("asgi.type", ty)
158 with hub.start_transaction(
159 transaction, custom_sampling_context={"asgi_scope": scope}
160 ):
161 # XXX: Would be cool to have correct span status, but we
162 # would have to wrap send(). That is a bit hard to do with
163 # the current abstraction over ASGI 2/3.
164 try:
165 return await callback()
166 except Exception as exc:
167 _capture_exception(hub, exc)
168 raise exc from None
169 finally:
170 _asgi_middleware_applied.set(False)
172 def event_processor(self, event, hint, asgi_scope):
173 # type: (Event, Hint, Any) -> Optional[Event]
174 request_info = event.get("request", {})
176 ty = asgi_scope["type"]
177 if ty in ("http", "websocket"):
178 request_info["method"] = asgi_scope.get("method")
179 request_info["headers"] = headers = _filter_headers(
180 self._get_headers(asgi_scope)
181 )
182 request_info["query_string"] = self._get_query(asgi_scope)
184 request_info["url"] = self._get_url(
185 asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
186 )
188 client = asgi_scope.get("client")
189 if client and _should_send_default_pii():
190 request_info["env"] = {"REMOTE_ADDR": self._get_ip(asgi_scope)}
192 self._set_transaction_name_and_source(event, self.transaction_style, asgi_scope)
194 event["request"] = request_info
196 return event
198 # Helper functions for extracting request data.
199 #
200 # Note: Those functions are not public API. If you want to mutate request
201 # data to your liking it's recommended to use the `before_send` callback
202 # for that.
204 def _set_transaction_name_and_source(self, event, transaction_style, asgi_scope):
205 # type: (Event, str, Any) -> None
207 transaction_name_already_set = (
208 event.get("transaction", _DEFAULT_TRANSACTION_NAME)
209 != _DEFAULT_TRANSACTION_NAME
210 )
211 if transaction_name_already_set:
212 return
214 name = ""
216 if transaction_style == "endpoint":
217 endpoint = asgi_scope.get("endpoint")
218 # Webframeworks like Starlette mutate the ASGI env once routing is
219 # done, which is sometime after the request has started. If we have
220 # an endpoint, overwrite our generic transaction name.
221 if endpoint:
222 name = transaction_from_function(endpoint) or ""
224 elif transaction_style == "url":
225 # FastAPI includes the route object in the scope to let Sentry extract the
226 # path from it for the transaction name
227 route = asgi_scope.get("route")
228 if route:
229 path = getattr(route, "path", None)
230 if path is not None:
231 name = path
233 if not name:
234 # If no transaction name can be found set an unknown source.
235 # This can happen when ASGI frameworks that are not yet supported well are used.
236 event["transaction_info"] = {"source": TRANSACTION_SOURCE_UNKNOWN}
237 return
239 event["transaction"] = name
240 event["transaction_info"] = {"source": SOURCE_FOR_STYLE[transaction_style]}
242 def _get_url(self, scope, default_scheme, host):
243 # type: (Dict[str, Any], Literal["ws", "http"], Optional[str]) -> str
244 """
245 Extract URL from the ASGI scope, without also including the querystring.
246 """
247 scheme = scope.get("scheme", default_scheme)
249 server = scope.get("server", None)
250 path = scope.get("root_path", "") + scope.get("path", "")
252 if host:
253 return "%s://%s%s" % (scheme, host, path)
255 if server is not None:
256 host, port = server
257 default_port = {"http": 80, "https": 443, "ws": 80, "wss": 443}[scheme]
258 if port != default_port:
259 return "%s://%s:%s%s" % (scheme, host, port, path)
260 return "%s://%s%s" % (scheme, host, path)
261 return path
263 def _get_query(self, scope):
264 # type: (Any) -> Any
265 """
266 Extract querystring from the ASGI scope, in the format that the Sentry protocol expects.
267 """
268 qs = scope.get("query_string")
269 if not qs:
270 return None
271 return urllib.parse.unquote(qs.decode("latin-1"))
273 def _get_ip(self, scope):
274 # type: (Any) -> str
275 """
276 Extract IP Address from the ASGI scope based on request headers with fallback to scope client.
277 """
278 headers = self._get_headers(scope)
279 try:
280 return headers["x-forwarded-for"].split(",")[0].strip()
281 except (KeyError, IndexError):
282 pass
284 try:
285 return headers["x-real-ip"]
286 except KeyError:
287 pass
289 return scope.get("client")[0]
291 def _get_headers(self, scope):
292 # type: (Any) -> Dict[str, str]
293 """
294 Extract headers from the ASGI scope, in the format that the Sentry protocol expects.
295 """
296 headers = {} # type: Dict[str, str]
297 for raw_key, raw_value in scope["headers"]:
298 key = raw_key.decode("latin-1")
299 value = raw_value.decode("latin-1")
300 if key in headers:
301 headers[key] = headers[key] + ", " + value
302 else:
303 headers[key] = value
304 return headers