diff --git a/Dockerfile b/Dockerfile index 0315d93..61e0cc1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ RUN cd ui \ && npm run build FROM base as dev -RUN pdm install --no-lock -G dev -G lint --no-editable +RUN pdm install --no-lock -G dev -G sentry -G lint --no-editable FROM base as test RUN pdm install --no-lock -G test diff --git a/README.md b/README.md index e6c7407..7abc5a6 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Installation On PyPi: https://pypi.org/project/evo-featureflags-server +To install with Sentry integration: +`pip3 install evo-featureflags-server[sentry]` + To install client library follow instructions here: [evo-featureflags-client](https://github.com/evo-company/featureflags-py) diff --git a/configs/local.yaml b/configs/local.yaml index 6477051..f34f758 100644 --- a/configs/local.yaml +++ b/configs/local.yaml @@ -36,3 +36,11 @@ ldap: instrumentation: prometheus_port: 9100 + +sentry: + enabled: false + dsn: null + env: local + enable_tracing: true + traces_sample_rate: 1 + shutdown_timeout: 1 diff --git a/configs/test.yaml b/configs/test.yaml index 9083946..6b9b9fb 100644 --- a/configs/test.yaml +++ b/configs/test.yaml @@ -36,3 +36,11 @@ ldap: instrumentation: prometheus_port: null + +sentry: + enabled: false + dsn: null + env: local + enable_tracing: true + traces_sample_rate: 1 + shutdown_timeout: 1 diff --git a/featureflags/cli.py b/featureflags/cli.py index 9dd9bf8..e82a6e3 100644 --- a/featureflags/cli.py +++ b/featureflags/cli.py @@ -1,3 +1,4 @@ +import logging from typing import Annotated import typer @@ -6,6 +7,7 @@ configure_logging(__package__) +log = logging.getLogger(__name__) cli = typer.Typer() @@ -16,6 +18,7 @@ def alembic(args: Annotated[list[str], typer.Argument()]) -> None: from featureflags.alembic import main as alembic_main + log.info("Executing command: `alembic`") alembic_main(args) @@ -23,6 +26,7 @@ def alembic(args: Annotated[list[str], typer.Argument()]) -> None: def web() -> None: from featureflags.web.app import main as web_main + log.info("Executing command: `web`") web_main() @@ -34,6 +38,7 @@ def rpc() -> None: from featureflags.rpc.app import main as rpc_main + log.info("Executing command: `rpc`") asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) asyncio.run(rpc_main()) @@ -42,4 +47,5 @@ def rpc() -> None: def http() -> None: from featureflags.http.app import main as http_main + log.info("Executing command: `http`") http_main() diff --git a/featureflags/config.py b/featureflags/config.py index 40c7e3c..efb0252 100644 --- a/featureflags/config.py +++ b/featureflags/config.py @@ -71,6 +71,15 @@ class RpcSettings(BaseSettings): host: str = "0.0.0.0" +class SentrySettings(BaseSettings): + enabled: bool = False + dsn: str | None = None + env: str | None = None + enable_tracing: bool = True + traces_sample_rate: float = 1.0 + shutdown_timeout: int = 1 + + class Config(BaseSettings): debug: bool secret: str = Field(..., alias="SECRET") @@ -80,6 +89,7 @@ class Config(BaseSettings): ldap: LdapSettings logging: LoggingSettings instrumentation: InstrumentationSettings + sentry: SentrySettings app: AppSettings rpc: RpcSettings diff --git a/featureflags/graph/actions.py b/featureflags/graph/actions.py index 56e7710..39e52e5 100644 --- a/featureflags/graph/actions.py +++ b/featureflags/graph/actions.py @@ -1,8 +1,8 @@ from datetime import datetime -from uuid import UUID, uuid4 +from uuid import UUID from aiopg.sa import SAConnection -from sqlalchemy import and_, or_, select, update +from sqlalchemy import or_, select, update from sqlalchemy.dialects.postgresql import insert from featureflags.graph.constants import AUTH_SESSION_TTL @@ -15,15 +15,13 @@ DirtyProjects, LocalId, ) -from featureflags.graph.utils import update_map +from featureflags.graph.utils import gen_id, get_auth_user, update_map from featureflags.models import ( AuthSession, - AuthUser, Changelog, Check, Condition, Flag, - LocalIdMap, Operator, Project, Variable, @@ -33,64 +31,6 @@ from featureflags.utils import select_scalar -async def gen_id(local_id: LocalId, *, conn: SAConnection) -> UUID: - assert local_id.scope and local_id.value, local_id - - id_ = await select_scalar( - conn, - ( - insert(LocalIdMap.__table__) - .values( - { - LocalIdMap.scope: local_id.scope, - LocalIdMap.value: local_id.value, - LocalIdMap.id: uuid4(), - LocalIdMap.timestamp: datetime.utcnow(), - } - ) - .on_conflict_do_nothing() - .returning(LocalIdMap.id) - ), - ) - if id_ is None: - id_ = await select_scalar( - conn, - ( - select([LocalIdMap.id]).where( - and_( - LocalIdMap.scope == local_id.scope, - LocalIdMap.value == local_id.value, - ) - ) - ), - ) - return id_ - - -async def get_auth_user(username: str, *, conn: SAConnection) -> UUID: - user_id_select = select([AuthUser.id]).where(AuthUser.username == username) - user_id = await select_scalar(conn, user_id_select) - if user_id is None: - user_id = await select_scalar( - conn, - ( - insert(AuthUser.__table__) - .values( - { - AuthUser.id: uuid4(), - AuthUser.username: username, - } - ) - .on_conflict_do_nothing() - .returning(AuthUser.id) - ), - ) - if user_id is None: - user_id = await select_scalar(conn, user_id_select) - assert user_id is not None - return user_id - - @track async def sign_in( username: str, diff --git a/featureflags/graph/utils.py b/featureflags/graph/utils.py index fc56fcd..ee76241 100644 --- a/featureflags/graph/utils.py +++ b/featureflags/graph/utils.py @@ -1,4 +1,19 @@ import uuid +from datetime import datetime +from uuid import UUID, uuid4 + +from aiopg.sa import SAConnection +from sqlalchemy import and_, select +from sqlalchemy.dialects.postgresql import insert + +from featureflags.graph.types import ( + LocalId, +) +from featureflags.models import ( + AuthUser, + LocalIdMap, +) +from featureflags.utils import select_scalar def is_valid_uuid(value: str) -> bool: @@ -14,3 +29,61 @@ def update_map(map_: dict, update: dict) -> dict: map_ = map_.copy() map_.update(update) return map_ + + +async def gen_id(local_id: LocalId, *, conn: SAConnection) -> UUID: + assert local_id.scope and local_id.value, local_id + + id_ = await select_scalar( + conn, + ( + insert(LocalIdMap.__table__) + .values( + { + LocalIdMap.scope: local_id.scope, + LocalIdMap.value: local_id.value, + LocalIdMap.id: uuid4(), + LocalIdMap.timestamp: datetime.utcnow(), + } + ) + .on_conflict_do_nothing() + .returning(LocalIdMap.id) + ), + ) + if id_ is None: + id_ = await select_scalar( + conn, + ( + select([LocalIdMap.id]).where( + and_( + LocalIdMap.scope == local_id.scope, + LocalIdMap.value == local_id.value, + ) + ) + ), + ) + return id_ + + +async def get_auth_user(username: str, *, conn: SAConnection) -> UUID: + user_id_select = select([AuthUser.id]).where(AuthUser.username == username) + user_id = await select_scalar(conn, user_id_select) + if user_id is None: + user_id = await select_scalar( + conn, + ( + insert(AuthUser.__table__) + .values( + { + AuthUser.id: uuid4(), + AuthUser.username: username, + } + ) + .on_conflict_do_nothing() + .returning(AuthUser.id) + ), + ) + if user_id is None: + user_id = await select_scalar(conn, user_id_select) + assert user_id is not None + return user_id diff --git a/featureflags/http/app.py b/featureflags/http/app.py index d94c8ea..68028df 100644 --- a/featureflags/http/app.py +++ b/featureflags/http/app.py @@ -24,9 +24,14 @@ def create_app() -> FastAPI: set_internal_user_session() - configure_metrics(port=config.instrumentation.prometheus_port) + configure_metrics(port=config.instrumentation.prometheus_port, app=app) configure_lifecycle(app, container) + if config.sentry.enabled: + from featureflags.sentry import configure_sentry + + configure_sentry(config.sentry, env_prefix="http", app=app) + return app @@ -41,4 +46,5 @@ def main() -> None: loop="uvloop", http="httptools", reload=config.http.reload, + log_level="debug" if config.debug else "info", ) diff --git a/featureflags/http/repositories/flags.py b/featureflags/http/repositories/flags.py index a2c76c6..314185a 100644 --- a/featureflags/http/repositories/flags.py +++ b/featureflags/http/repositories/flags.py @@ -58,7 +58,7 @@ def __init__( self._entity_cache = EntityCache() - async def get_current_version(self, project: str) -> int: + async def get_project_version(self, project: str) -> int: async with self._db_engine.acquire() as conn: return await select_scalar( conn, @@ -88,7 +88,7 @@ async def load(self, request: PreloadFlagsRequest) -> PreloadFlagsResponse: await self.prepare_project(request) - current_version = await self.get_current_version(request.project) + current_version = await self.get_project_version(request.project) result = await exec_denormalize_graph( graph_engine=self._graph_engine, @@ -109,7 +109,7 @@ async def sync(self, request: SyncFlagsRequest) -> SyncFlagsResponse: is different from the requested one. """ - current_version = await self.get_current_version(request.project) + current_version = await self.get_project_version(request.project) if request.version != current_version: result = await exec_denormalize_graph( diff --git a/featureflags/http/types.py b/featureflags/http/types.py index ad5976b..76ecb91 100644 --- a/featureflags/http/types.py +++ b/featureflags/http/types.py @@ -29,6 +29,11 @@ def check_and_assign_value( cls, # noqa: N805 values: dict[str, Any], ) -> dict[str, Any]: + """ + Value can be any type from `CHECK_VALUE_FIELDS`, but we want + to find one that is not not and assign to `Check.value`. + """ + for field in CHECK_VALUE_FIELDS: if field in values and values[field] is not None: values["value"] = values[field] diff --git a/featureflags/logging.py b/featureflags/logging.py index 013d938..f44eb85 100644 --- a/featureflags/logging.py +++ b/featureflags/logging.py @@ -6,8 +6,6 @@ from featureflags.config import config -log = logging.getLogger(__name__) - def create_console_handler() -> StreamHandler: handler = StreamHandler() diff --git a/featureflags/metrics.py b/featureflags/metrics.py index 16f2cf6..aa3b451 100644 --- a/featureflags/metrics.py +++ b/featureflags/metrics.py @@ -3,8 +3,10 @@ from contextlib import AbstractContextManager from typing import Any +from fastapi import FastAPI from prometheus_client import start_http_server from prometheus_client.decorator import decorator as prometheus_decorator +from prometheus_fastapi_instrumentator import Instrumentator log = logging.getLogger(__name__) @@ -17,9 +19,26 @@ async def wrapper(fn: Callable, *args: Any, **kwargs: Any) -> None: return prometheus_decorator(wrapper) -def configure_metrics(port: int | None = None) -> None: +def configure_metrics( + port: int | None = None, + app: FastAPI | None = None, +) -> None: if port: start_http_server(port=port) log.info("Prometheus metrics initialized") + + if app: + instrumentator = Instrumentator( + should_instrument_requests_inprogress=True, + inprogress_labels=True, + excluded_handlers=["/metrics", "/~health"], + ) + instrumentator.instrument( + app=app, + latency_lowr_buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, + 0.25, 0.5], + ) + log.info("Http instrumentation initialized") + else: log.info("Prometheus metrics disabled") diff --git a/featureflags/rpc/app.py b/featureflags/rpc/app.py index eacd90e..65864c8 100644 --- a/featureflags/rpc/app.py +++ b/featureflags/rpc/app.py @@ -40,8 +40,14 @@ async def main() -> None: container = Container() await container.init_resources() + log.info("Using internal user session") set_internal_user_session() + if config.sentry.enabled: + from featureflags.sentry import configure_sentry + + configure_sentry(config.sentry, env_prefix="rpc") + server = await create_server() stack.enter_context(graceful_exit([server])) # type: ignore diff --git a/featureflags/sentry.py b/featureflags/sentry.py new file mode 100644 index 0000000..138626a --- /dev/null +++ b/featureflags/sentry.py @@ -0,0 +1,68 @@ +import logging + +from fastapi import FastAPI + +try: + import sentry_sdk + from sentry_sdk.integrations.asgi import SentryAsgiMiddleware + from sentry_sdk.integrations.asyncio import AsyncioIntegration + from sentry_sdk.integrations.atexit import AtexitIntegration + from sentry_sdk.integrations.dedupe import DedupeIntegration + from sentry_sdk.integrations.excepthook import ExcepthookIntegration + from sentry_sdk.integrations.grpc import GRPCIntegration + from sentry_sdk.integrations.logging import LoggingIntegration + from sentry_sdk.integrations.stdlib import StdlibIntegration + from sentry_sdk.integrations.threading import ThreadingIntegration + from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration +except ImportError: + raise ImportError( + "`sentry_sdk` is not installed, please install it to use `sentry` " + "like this `pip install 'evo-featureflags-server[sentry]'`" + ) from None + +from featureflags import __version__ +from featureflags.config import SentrySettings + +log = logging.getLogger(__name__) + + +def configure_sentry( + config: SentrySettings, + env_prefix: str | None = None, + app: FastAPI | None = None, +) -> None: + """ + Configure error logging to Sentry. + """ + + env = f"{env_prefix}-{config.env}" if env_prefix else config.env + + sentry_sdk.init( + dsn=config.dsn, + environment=env, + release=__version__, + shutdown_timeout=config.shutdown_timeout, + send_default_pii=True, + default_integrations=False, + auto_enabling_integrations=False, + max_breadcrumbs=1000, + enable_tracing=config.enable_tracing, + traces_sample_rate=config.traces_sample_rate, + integrations=[ + AsyncioIntegration(), + AtexitIntegration(), + ExcepthookIntegration(), + DedupeIntegration(), + StdlibIntegration(), + ThreadingIntegration(), + LoggingIntegration(), + GRPCIntegration(), + SqlalchemyIntegration(), + ], + ) + + if app is not None: + # Add FastApi specific middleware. + app.add_middleware(SentryAsgiMiddleware) + + log.info(f"Sentry initialized with env: `{env}`") diff --git a/featureflags/web/app.py b/featureflags/web/app.py index 991bc5b..56cbe2d 100644 --- a/featureflags/web/app.py +++ b/featureflags/web/app.py @@ -33,10 +33,15 @@ def create_app() -> FastAPI: ) app.mount("/static", static_files, name="static") - configure_metrics(port=config.instrumentation.prometheus_port) + configure_metrics(port=config.instrumentation.prometheus_port, app=app) configure_middlewares(app, container) configure_lifecycle(app, container) + if config.sentry.enabled: + from featureflags.sentry import configure_sentry + + configure_sentry(config.sentry, env_prefix="web", app=app) + return app @@ -51,4 +56,5 @@ def main() -> None: loop="uvloop", http="httptools", reload=config.app.reload, + log_level="debug" if config.debug else "info", ) diff --git a/pdm.lock b/pdm.lock index f8d179b..412870c 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,10 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "dev", "docs", "lint", "test"] +groups = ["default", "dev", "docs", "lint", "test", "sentry"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:26bc77c5bac3d57bf72c17831eb85804d3b512ce4b5907e21ede287959fbc3ea" +content_hash = "sha256:f58ff5ada62f011fb7b2dd8897413cbaa02369b9de83f1657b685180a6b11c3a" [[package]] name = "aiopg" @@ -70,7 +70,7 @@ name = "annotated-types" version = "0.6.0" requires_python = ">=3.8" summary = "Reusable constraint types to use with typing.Annotated" -groups = ["default"] +groups = ["default", "sentry"] files = [ {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, @@ -81,7 +81,7 @@ name = "anyio" version = "4.2.0" requires_python = ">=3.8" summary = "High level compatibility layer for multiple asynchronous event loop implementations" -groups = ["default", "dev"] +groups = ["default", "dev", "sentry"] dependencies = [ "idna>=2.8", "sniffio>=1.1", @@ -186,7 +186,7 @@ name = "certifi" version = "2023.11.17" requires_python = ">=3.6" summary = "Python package for providing Mozilla's CA Bundle." -groups = ["docs"] +groups = ["docs", "sentry"] files = [ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, @@ -367,7 +367,7 @@ name = "fastapi" version = "0.108.0" requires_python = ">=3.8" summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -groups = ["default"] +groups = ["default", "sentry"] dependencies = [ "pydantic!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0,>=1.7.4", "starlette<0.33.0,>=0.29.0", @@ -559,7 +559,7 @@ name = "idna" version = "3.6" requires_python = ">=3.5" summary = "Internationalized Domain Names in Applications (IDNA)" -groups = ["default", "dev", "docs"] +groups = ["default", "dev", "docs", "sentry"] files = [ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, @@ -900,6 +900,21 @@ files = [ {file = "prometheus_client-0.17.1.tar.gz", hash = "sha256:21e674f39831ae3f8acde238afd9a27a37d0d2fb5a28ea094f0ce25d2cbf2091"}, ] +[[package]] +name = "prometheus-fastapi-instrumentator" +version = "6.1.0" +requires_python = ">=3.7.0,<4.0.0" +summary = "Instrument your FastAPI with Prometheus metrics." +groups = ["default"] +dependencies = [ + "fastapi<1.0.0,>=0.38.1", + "prometheus-client<1.0.0,>=0.8.0", +] +files = [ + {file = "prometheus_fastapi_instrumentator-6.1.0-py3-none-any.whl", hash = "sha256:2279ac1cf5b9566a4c3a07f78c9c5ee19648ed90976ab87d73d672abc1bfa017"}, + {file = "prometheus_fastapi_instrumentator-6.1.0.tar.gz", hash = "sha256:1820d7a90389ce100f7d1285495ead388818ae0882e761c1f3e6e62a410bdf13"}, +] + [[package]] name = "prompt-toolkit" version = "3.0.43" @@ -1008,7 +1023,7 @@ name = "pydantic" version = "2.5.3" requires_python = ">=3.7" summary = "Data validation using Python type hints" -groups = ["default"] +groups = ["default", "sentry"] dependencies = [ "annotated-types>=0.4.0", "pydantic-core==2.14.6", @@ -1024,7 +1039,7 @@ name = "pydantic-core" version = "2.14.6" requires_python = ">=3.7" summary = "" -groups = ["default"] +groups = ["default", "sentry"] dependencies = [ "typing-extensions!=4.7.0,>=4.6.0", ] @@ -1262,6 +1277,35 @@ files = [ {file = "ruff-0.1.11.tar.gz", hash = "sha256:f9d4d88cb6eeb4dfe20f9f0519bd2eaba8119bde87c3d5065c541dbae2b5a2cb"}, ] +[[package]] +name = "sentry-sdk" +version = "1.40.4" +summary = "Python client for Sentry (https://sentry.io)" +groups = ["sentry"] +dependencies = [ + "certifi", + "urllib3>=1.26.11; python_version >= \"3.6\"", +] +files = [ + {file = "sentry-sdk-1.40.4.tar.gz", hash = "sha256:657abae98b0050a0316f0873d7149f951574ae6212f71d2e3a1c4c88f62d6456"}, + {file = "sentry_sdk-1.40.4-py2.py3-none-any.whl", hash = "sha256:ac5cf56bb897ec47135d239ddeedf7c1c12d406fb031a4c0caa07399ed014d7e"}, +] + +[[package]] +name = "sentry-sdk" +version = "1.40.4" +extras = ["fastapi"] +summary = "Python client for Sentry (https://sentry.io)" +groups = ["sentry"] +dependencies = [ + "fastapi>=0.79.0", + "sentry-sdk==1.40.4", +] +files = [ + {file = "sentry-sdk-1.40.4.tar.gz", hash = "sha256:657abae98b0050a0316f0873d7149f951574ae6212f71d2e3a1c4c88f62d6456"}, + {file = "sentry_sdk-1.40.4-py2.py3-none-any.whl", hash = "sha256:ac5cf56bb897ec47135d239ddeedf7c1c12d406fb031a4c0caa07399ed014d7e"}, +] + [[package]] name = "setuptools" version = "68.2.2" @@ -1289,7 +1333,7 @@ name = "sniffio" version = "1.3.0" requires_python = ">=3.7" summary = "Sniff out which async library your code is running under" -groups = ["default", "dev"] +groups = ["default", "dev", "sentry"] files = [ {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, @@ -1565,7 +1609,7 @@ name = "starlette" version = "0.32.0.post1" requires_python = ">=3.8" summary = "The little ASGI library that shines." -groups = ["default"] +groups = ["default", "sentry"] dependencies = [ "anyio<5,>=3.4.0", ] @@ -1662,7 +1706,7 @@ name = "typing-extensions" version = "4.9.0" requires_python = ">=3.8" summary = "Backported and Experimental Type Hints for Python 3.8+" -groups = ["default", "lint"] +groups = ["default", "lint", "sentry"] files = [ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, @@ -1673,7 +1717,7 @@ name = "urllib3" version = "2.1.0" requires_python = ">=3.8" summary = "HTTP library with thread-safe connection pooling, file post, and more." -groups = ["docs"] +groups = ["docs", "sentry"] files = [ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, diff --git a/pyproject.toml b/pyproject.toml index 60348de..245ca7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,10 +34,16 @@ dependencies = [ "jinja2>=3.1.2", "dependency-injector>=4.41.0", "grpcio>=1.59.0", + "prometheus-fastapi-instrumentator>=6.1.0", ] requires-python = ">=3.11" license = {text = "MIT"} +[project.optional-dependencies] +sentry = [ + "sentry-sdk[fastapi]>=1.40.4", +] + [build-system] requires = ["pdm-backend"] build-backend = "pdm.backend"