From 63755a433835444b2c5f76daacb22b0145997ab9 Mon Sep 17 00:00:00 2001 From: slaveeks Date: Mon, 18 Nov 2024 00:43:19 +0300 Subject: [PATCH 1/2] feat: added fastapi integration --- README.md | 4 +- docs/fastapi.md | 100 ++++++++++++++++++++ pyproject.toml | 3 +- src/hawkcatcher/core.py | 29 +++++- src/hawkcatcher/modules/fastapi/__init__.py | 13 +++ src/hawkcatcher/modules/fastapi/fastapi.py | 96 +++++++++++++++++++ src/hawkcatcher/modules/fastapi/types.py | 19 ++++ src/hawkcatcher/modules/flask/flask.py | 26 +---- src/hawkcatcher/modules/flask/types.py | 11 +-- src/hawkcatcher/types.py | 13 ++- 10 files changed, 277 insertions(+), 37 deletions(-) create mode 100644 docs/fastapi.md create mode 100644 src/hawkcatcher/modules/fastapi/__init__.py create mode 100644 src/hawkcatcher/modules/fastapi/fastapi.py create mode 100644 src/hawkcatcher/modules/fastapi/types.py diff --git a/README.md b/README.md index 93a7474..25d7280 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Python errors Catcher module for [Hawk.so](https://hawk.so). Register an account and get a new project token. +If you want to connect specific frameworks see [Flask integration](./docs/flask.md), [FastAPI integration](./docs/fastapi.md). + ### Install module Install `hawkcatcher` from PyPI. @@ -104,7 +106,7 @@ Parameters: ## Requirements -- Python \>= 3.5 +- Python \>= 3.9 - requests ## Links diff --git a/docs/fastapi.md b/docs/fastapi.md new file mode 100644 index 0000000..5cd9804 --- /dev/null +++ b/docs/fastapi.md @@ -0,0 +1,100 @@ +# Flask integration + +This extension adds support for the [FastAPI](https://fastapi.tiangolo.com/) web framework. + +## Installation + +```bash +pip install hawkcatcher[fastapi] +``` + +import Catcher module to your project. + +```python +from hawkcatcher.modules.fastapi import HawkFastapi +``` + +```python +app = FastAPI() + +hawk = HawkFastapi( + 'app_instance': app, + 'token': '1234567-abcd-8901-efgh-123456789012' +) +``` + +Now all global fastapi errors would be sent to Hawk. + +### Try-except + +If you want to catch errors in try-except blocks see [this](../README.md#try-except) + +## Manual sending + +You can send any error to Hawk. See [this](../README.md#manual-sending) + +### Event context + +See [this](../README.md#event-context) + +### Affected user + +See [this](../README.md#affected-user) + +### Addons + +When some event handled by FastAPI Catcher, it adds some addons to the event data for Hawk. + +| name | type | description | +| --------- | ---- | --------------- | +| `url` | str | Request URL | +| `method` | str | Request method | +| `headers` | dict | Request headers | +| `cookies` | dict | Request cookies | +| `params` | dict | Request params | + +## Init params + +To init Hawk Catcher just pass a project token and FastAPI app instance. + +```python +app = FastAPI() + +hawk = HawkFastapi( + 'app_instance': app, + 'token': '1234567-abcd-8901-efgh-123456789012' +) +``` + +### Additional params + +If you need to use custom Hawk server then pass a dictionary with params. + +```python +hawk = HawkFastapi({ + 'app_instance': app, + 'token': '1234567-abcd-8901-efgh-123456789012', + 'collector_endpoint': 'https://.k1.hawk.so', +}) +``` + +Parameters: + +| name | type | required | description | +| -------------------- | ------------------------- | ------------ | ------------------------------------------------------------------------------ | +| `app_instance` | FastAPI | **required** | FastAPI app instance | +| `token` | str | **required** | Your project's Integration Token | +| `release` | str | optional | Release name for Suspected Commits feature | +| `collector_endpoint` | string | optional | Collector endpoint for sending event to | +| `context` | dict | optional | Additional context to be send with every event | +| `before_send` | Callable[[dict], None] | optional | This Method allows you to filter any data you don't want sending to Hawk | +| `set_user` | Callable[[Request], User] | optional | This Method allows you to set user for every request by fastapi request object | +| `with_addons` | bool | optional | Add framework addons to event data | + +## Requirements + +See [this](../README.md#requirements) + +And for fastapi you need: + +- fastapi diff --git a/pyproject.toml b/pyproject.toml index 0f65325..06dbb00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ name = "hawkcatcher" authors = [{ name = "CodeX Team", email = "team@codex.so" }] description = "Python errors Catcher module for Hawk." readme = "README.md" -requires-python = ">=3.5" +requires-python = ">=3.9" classifiers = [ "Intended Audience :: Developers", "Topic :: Software Development :: Bug Tracking", @@ -19,6 +19,7 @@ classifiers = [ ] [project.optional-dependencies] flask = ["flask"] +fastapi = ["starlette"] [tool.hatch.version] path = "src/hawkcatcher/__init__.py" [project.urls] diff --git a/src/hawkcatcher/core.py b/src/hawkcatcher/core.py index 63954ff..095cc4a 100644 --- a/src/hawkcatcher/core.py +++ b/src/hawkcatcher/core.py @@ -9,7 +9,7 @@ import hawkcatcher from hawkcatcher.errors import InvalidHawkToken -from hawkcatcher.types import HawkCatcherSettings +from hawkcatcher.types import HawkCatcherSettings, Addons, User class Hawk: @@ -49,7 +49,7 @@ def get_params(settings) -> Union[HawkCatcherSettings, None]: 'context': settings.get('context', None) } - def handler(self, exc_cls: type, exc: Exception, tb: traceback, context=None, user=None, addons=None): + def handler(self, exc_cls: type, exc: Exception, tb: traceback, context=None, user=None): """ Catch, prepare and send error @@ -70,6 +70,7 @@ def handler(self, exc_cls: type, exc: Exception, tb: traceback, context=None, us ex_message = traceback.format_exception_only(exc_cls, exc)[-1] ex_message = ex_message.strip() backtrace = tb and Hawk.parse_traceback(tb) + addons = self._set_addons() if not (type(context) is dict): context = { @@ -108,7 +109,7 @@ def send_to_collector(self, event): except Exception as e: print('[Hawk] Can\'t send error cause of %s' % e) - def send(self, event: Exception = None, context=None, user=None, addons=None): + def send(self, event: Exception = None, context=None, user=None): """ Method for manually send error to Hawk :param event: event to send @@ -119,9 +120,27 @@ def send(self, event: Exception = None, context=None, user=None, addons=None): exc_cls, exc, tb = sys.exc_info() if event is not None: - self.handler(type(event), event, tb, context, user, addons) + self.handler(type(event), event, tb, context, user) else: - self.handler(exc_cls, exc, tb, context, user, addons) + self.handler(exc_cls, exc, tb, context, user) + + def _set_addons(self) -> Union[Addons, None]: + """ + Set framework addons to send with error + """ + return None + + def _set_user(self, request) -> Union[User, None]: + """ + Set user information by set_user callback + """ + user = None + + if self.params.get('set_user') is not None: + user = self.params['set_user'](request) + + return user + @staticmethod def parse_traceback(tb): diff --git a/src/hawkcatcher/modules/fastapi/__init__.py b/src/hawkcatcher/modules/fastapi/__init__.py new file mode 100644 index 0000000..e763dec --- /dev/null +++ b/src/hawkcatcher/modules/fastapi/__init__.py @@ -0,0 +1,13 @@ +from .fastapi import HawkFastapi +from .types import HawkCatcherSettings +from .types import FastapiSettings + +hawk = HawkFastapi() + + +def init(*args, **kwargs): + hawk.init(*args, **kwargs) + + +def send(*args, **kwargs): + hawk.send(*args, **kwargs) \ No newline at end of file diff --git a/src/hawkcatcher/modules/fastapi/fastapi.py b/src/hawkcatcher/modules/fastapi/fastapi.py new file mode 100644 index 0000000..8ea2007 --- /dev/null +++ b/src/hawkcatcher/modules/fastapi/fastapi.py @@ -0,0 +1,96 @@ +from hawkcatcher.types import HawkCatcherSettings +from ...core import Hawk +from hawkcatcher.modules.fastapi.types import FastapiSettings, FastapiAddons +from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.requests import Request +from starlette.middleware.base import BaseHTTPMiddleware +from typing import Union +from hawkcatcher.errors import ModuleError +import asyncio +from contextvars import ContextVar +from fastapi import Request +import asyncio + +# Variable for saving current request, work with async tasks +current_request: ContextVar[Union[Request, None]] = ContextVar("current_request", default=None) + + +# class for catching errors in fastapi app +class HawkFastapi(Hawk): + params: FastapiSettings = {} + + def init(self, settings: Union[str, FastapiSettings] = None): + self.params = self.get_params(settings) + + if self.params.get('app_instance') is None: + raise ModuleError('Fastapi app instance not passed to HawkFastapi') + + self.params.get('app_instance').add_middleware(self._get_starlette_middleware()) + + def _get_starlette_middleware(self): + """ + Create middleware for starlette to identify request exception and storing current request for manual sending + """ + + # Create method to use it in middleware class with Hawk class context + def send_func(err): + return self.send(err) + + class StarletteMiddleware: + def __init__(self, app: ASGIApp): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + if scope["type"] == "http": + request = Request(scope, receive, send) + current_request.set(request) + try: + await self.app(scope, receive, send) + except Exception as err: + return send_func(err) + else: + await self.app(scope, receive, send) + return None + + return StarletteMiddleware + + def send(self, event: Exception = None, context=None, user=None): + """ + Method for manually send error to Hawk, make it async for starlette + :param exception: exception + :param context: additional context to send with error + :param user: user information who faced with that event + """ + + request = current_request.get() + + if user is None and request is not None: + user = self._set_user(request) + + return super().send(event, context, user) + + def _set_addons(self) -> Union[FastapiAddons, None]: + request = current_request.get() + + if request is None: + return None + + return { + 'url': str(request.url), + 'method': request.method, + 'headers': dict(request.headers), + 'cookies': dict(request.cookies), + 'params': dict(request.query_params) + } + + @staticmethod + def get_params(settings) -> FastapiSettings | None: + hawk_params = Hawk.get_params(settings) + + if hawk_params is None: + return None + + return { + **hawk_params, + 'app_instance': settings.get('app_instance'), + } \ No newline at end of file diff --git a/src/hawkcatcher/modules/fastapi/types.py b/src/hawkcatcher/modules/fastapi/types.py new file mode 100644 index 0000000..b2d8ea1 --- /dev/null +++ b/src/hawkcatcher/modules/fastapi/types.py @@ -0,0 +1,19 @@ +from hawkcatcher.types import HawkCatcherSettings, User, Addons +from typing import Callable, TypedDict +from starlette.applications import Starlette +from fastapi import Request + +class FastapiAddons(TypedDict): + url: str # url of request + method: str # method of request + headers: dict # headers of request + cookies: dict # cookies of request + params: dict # request params + +class Addons(Addons): + fastapi: FastapiAddons + +class FastapiSettings(HawkCatcherSettings[Request]): + """Settings for Fastapi catcher for errors tracking""" + + app_instance: Starlette # Fastapi app instance to add catching \ No newline at end of file diff --git a/src/hawkcatcher/modules/flask/flask.py b/src/hawkcatcher/modules/flask/flask.py index ec37217..27dc419 100644 --- a/src/hawkcatcher/modules/flask/flask.py +++ b/src/hawkcatcher/modules/flask/flask.py @@ -1,6 +1,6 @@ from ...core import Hawk from typing import Union -from hawkcatcher.modules.flask.types import FlaskSettings, User, Addons +from hawkcatcher.modules.flask.types import FlaskSettings, Addons from hawkcatcher.errors import ModuleError try: @@ -27,23 +27,20 @@ def get_params(settings) -> Union[FlaskSettings, None]: return { **hawk_params, 'set_user': settings.get('set_user'), - 'with_addons': settings.get('with_addons', True) } - def send(self, exception, context=None, user=None, addons=None): + def send(self, exception, context=None, user=None): """ Method for manually send error to Hawk :param exception: exception :param context: additional context to send with error :param user: user information who faced with that event """ - if addons is None: - addons = self._set_addons() - if user is None: + if (user is None) and (request): user = self._set_user(request) - super().send(exception, context, user, addons) + super().send(exception, context, user) def _handle_request_exception(self, sender: Flask, exception): """ @@ -52,13 +49,12 @@ def _handle_request_exception(self, sender: Flask, exception): :param sender: flask app :param exception: exception """ - addons = self._set_addons() user = self._set_user(request) ctx = self.params.get('context', None) - self.send(exception, ctx, user, addons) + self.send(exception, ctx, user) def _set_addons(self) -> Union[Addons, None]: """ @@ -83,15 +79,3 @@ def _set_addons(self) -> Union[Addons, None]: } return addons - - def _set_user(self, request) -> Union[User, None]: - """ - Set user information by set_user callback - """ - user = None - - if self.params.get('set_user') is not None: - user = self.params['set_user'](request) - - return user - diff --git a/src/hawkcatcher/modules/flask/types.py b/src/hawkcatcher/modules/flask/types.py index 850f5bf..354ee05 100644 --- a/src/hawkcatcher/modules/flask/types.py +++ b/src/hawkcatcher/modules/flask/types.py @@ -1,9 +1,8 @@ -from hawkcatcher.types import HawkCatcherSettings, User +from hawkcatcher.types import HawkCatcherSettings, User, Addons from typing import Callable, TypedDict from flask import Request class FlaskAddons(TypedDict): - app: str # name of flask app url: str # url of request method: str # method of request headers: dict # headers of request @@ -12,11 +11,9 @@ class FlaskAddons(TypedDict): form: dict # request form data json: dict # request json data -class Addons(TypedDict): +class Addons(Addons): flask: FlaskAddons -class FlaskSettings(HawkCatcherSettings): +class FlaskSettings(HawkCatcherSettings[Request]): """Settings for Flask catcher for errors tracking""" - - set_user: Callable[[Request], User] # This hook allows you to identify user - with_addons: bool = True # This parameter points if you want to send request data with error (cookies, headers, params, form, json) + pass diff --git a/src/hawkcatcher/types.py b/src/hawkcatcher/types.py index 38d5987..dd2652e 100644 --- a/src/hawkcatcher/types.py +++ b/src/hawkcatcher/types.py @@ -1,4 +1,7 @@ -from typing import TypedDict, Callable +from typing import TypedDict, Callable, TypeVar, Generic + + +T = TypeVar('T') class User(TypedDict): @@ -9,7 +12,7 @@ class User(TypedDict): image: str # User's public picture url: str # URL for user's details page -class HawkCatcherSettings(TypedDict): +class HawkCatcherSettings(TypedDict, Generic[T]): """Settings for Hawk catcher for errors tracking""" token: str # Hawk integration token @@ -17,3 +20,9 @@ class HawkCatcherSettings(TypedDict): release: str # Release name for Suspected Commits feature before_send: Callable[[dict], None] # This hook allows you to filter any data you don't want sending to Hawk context: dict # Additional context to be send with event + with_addons: bool = True # This parameter points if you want to send framework data with error (cookies, headers, params, form, json) + set_user: Callable[[T], User] # This hook allows you to set user information, this hook is useful for frameworks + +class Addons(TypedDict): + """Additional data to be send with event due to frameworks""" + pass \ No newline at end of file From 8ae1250a782e3462dc7795efae4917804b0de8ba Mon Sep 17 00:00:00 2001 From: slaveeks Date: Sun, 24 Nov 2024 16:13:33 +0300 Subject: [PATCH 2/2] fix: fixed fastapi addons object --- src/hawkcatcher/modules/fastapi/fastapi.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/hawkcatcher/modules/fastapi/fastapi.py b/src/hawkcatcher/modules/fastapi/fastapi.py index 8ea2007..a8e0502 100644 --- a/src/hawkcatcher/modules/fastapi/fastapi.py +++ b/src/hawkcatcher/modules/fastapi/fastapi.py @@ -76,11 +76,13 @@ def _set_addons(self) -> Union[FastapiAddons, None]: return None return { - 'url': str(request.url), - 'method': request.method, - 'headers': dict(request.headers), - 'cookies': dict(request.cookies), - 'params': dict(request.query_params) + 'fastapi': { + 'url': str(request.url), + 'method': request.method, + 'headers': dict(request.headers), + 'cookies': dict(request.cookies), + 'params': dict(request.query_params) + } } @staticmethod