From 23a7c25c766a9b5cf154f92a686ebc15310d868d Mon Sep 17 00:00:00 2001 From: Russ Allbery Date: Mon, 25 Nov 2024 13:45:41 -0800 Subject: [PATCH] Add Slack error reporting to fastapi_safir_app Add support for Slack error reporting of uncaught exceptions to the `fastapi_safir_app` project template. We are currently adding this to all of our applications, so there's no reason for it to not be in the template, even though this may be eventually replaced with Sentry or some other exception reporting mechanism. --- .../example-uws/src/exampleuws/config.py | 18 +++++++++++------ .../src/exampleuws/handlers/external.py | 3 ++- .../src/exampleuws/handlers/internal.py | 3 ++- .../example-uws/src/exampleuws/main.py | 9 +++++++++ .../example/src/example/config.py | 18 +++++++++++------ .../example/src/example/handlers/external.py | 3 ++- .../example/src/example/handlers/internal.py | 3 ++- .../example/src/example/main.py | 9 +++++++++ .../{{cookiecutter.module_name}}/config.py | 20 ++++++++++++------- .../handlers/external.py | 3 ++- .../handlers/internal.py | 3 ++- .../src/{{cookiecutter.module_name}}/main.py | 10 ++++++++++ .../technote_md/testn-000/technote.toml | 2 +- .../technote_rst/testn-000/technote.toml | 2 +- 14 files changed, 79 insertions(+), 27 deletions(-) diff --git a/project_templates/fastapi_safir_app/example-uws/src/exampleuws/config.py b/project_templates/fastapi_safir_app/example-uws/src/exampleuws/config.py index 10726c01..f1ff2e35 100644 --- a/project_templates/fastapi_safir_app/example-uws/src/exampleuws/config.py +++ b/project_templates/fastapi_safir_app/example-uws/src/exampleuws/config.py @@ -16,8 +16,16 @@ class Config(UWSAppSettings): """Configuration for example-uws.""" + model_config = SettingsConfigDict( + env_prefix="EXAMPLE_UWS_", case_sensitive=False + ) + name: str = Field("example-uws", title="Name of application") + log_level: LogLevel = Field( + LogLevel.INFO, title="Log level of the application's logger" + ) + path_prefix: str = Field( "/example-uws", title="URL prefix for application" ) @@ -26,12 +34,10 @@ class Config(UWSAppSettings): Profile.development, title="Application logging profile" ) - log_level: LogLevel = Field( - LogLevel.INFO, title="Log level of the application's logger" - ) - - model_config = SettingsConfigDict( - env_prefix="EXAMPLE_UWS_", case_sensitive=False + slack_webhook: SecretStr | None = Field( + None, + title="Slack webhook for alerts", + description="If set, alerts will be posted to this Slack webhook", ) @property diff --git a/project_templates/fastapi_safir_app/example-uws/src/exampleuws/handlers/external.py b/project_templates/fastapi_safir_app/example-uws/src/exampleuws/handlers/external.py index 1182a152..b4c418e4 100644 --- a/project_templates/fastapi_safir_app/example-uws/src/exampleuws/handlers/external.py +++ b/project_templates/fastapi_safir_app/example-uws/src/exampleuws/handlers/external.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends from safir.dependencies.logger import logger_dependency from safir.metadata import get_metadata +from safir.slack.webhook import SlackRouteErrorHandler from structlog.stdlib import BoundLogger from ..config import config @@ -12,7 +13,7 @@ __all__ = ["external_router"] -external_router = APIRouter() +external_router = APIRouter(route_class=SlackRouteErrorHandler) """FastAPI router for all external handlers.""" diff --git a/project_templates/fastapi_safir_app/example-uws/src/exampleuws/handlers/internal.py b/project_templates/fastapi_safir_app/example-uws/src/exampleuws/handlers/internal.py index 8096f147..bec3a34f 100644 --- a/project_templates/fastapi_safir_app/example-uws/src/exampleuws/handlers/internal.py +++ b/project_templates/fastapi_safir_app/example-uws/src/exampleuws/handlers/internal.py @@ -10,12 +10,13 @@ from fastapi import APIRouter from safir.metadata import Metadata, get_metadata +from safir.slack.webhook import SlackRouteErrorHandler from ..config import config __all__ = ["internal_router"] -internal_router = APIRouter() +internal_router = APIRouter(route_class=SlackRouteErrorHandler) """FastAPI router for all internal handlers.""" diff --git a/project_templates/fastapi_safir_app/example-uws/src/exampleuws/main.py b/project_templates/fastapi_safir_app/example-uws/src/exampleuws/main.py index f7bbcf16..7c1f2ca2 100644 --- a/project_templates/fastapi_safir_app/example-uws/src/exampleuws/main.py +++ b/project_templates/fastapi_safir_app/example-uws/src/exampleuws/main.py @@ -15,6 +15,7 @@ from safir.dependencies.http_client import http_client_dependency from safir.logging import configure_logging, configure_uvicorn_logging from safir.middleware.x_forwarded import XForwardedMiddleware +from safir.slack.webhook import SlackRouteErrorHandler from .config import config, uws from .handlers.external import external_router @@ -65,3 +66,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Install error handlers. uws.install_error_handlers(app) + +# Configure Slack alerts. +if config.slack_webhook: + logger = structlog.get_logger("exampleuws") + SlackRouteErrorHandler.initialize( + config.slack_webhook, "example-uws", logger + ) + logger.debug("Initialized Slack webhook") diff --git a/project_templates/fastapi_safir_app/example/src/example/config.py b/project_templates/fastapi_safir_app/example/src/example/config.py index 72ac0768..38640eb9 100644 --- a/project_templates/fastapi_safir_app/example/src/example/config.py +++ b/project_templates/fastapi_safir_app/example/src/example/config.py @@ -12,8 +12,16 @@ class Config(BaseSettings): """Configuration for example.""" + model_config = SettingsConfigDict( + env_prefix="EXAMPLE_", case_sensitive=False + ) + name: str = Field("example", title="Name of application") + log_level: LogLevel = Field( + LogLevel.INFO, title="Log level of the application's logger" + ) + path_prefix: str = Field( "/example", title="URL prefix for application" ) @@ -22,12 +30,10 @@ class Config(BaseSettings): Profile.development, title="Application logging profile" ) - log_level: LogLevel = Field( - LogLevel.INFO, title="Log level of the application's logger" - ) - - model_config = SettingsConfigDict( - env_prefix="EXAMPLE_", case_sensitive=False + slack_webhook: SecretStr | None = Field( + None, + title="Slack webhook for alerts", + description="If set, alerts will be posted to this Slack webhook", ) diff --git a/project_templates/fastapi_safir_app/example/src/example/handlers/external.py b/project_templates/fastapi_safir_app/example/src/example/handlers/external.py index fa1ccd42..8ac6b180 100644 --- a/project_templates/fastapi_safir_app/example/src/example/handlers/external.py +++ b/project_templates/fastapi_safir_app/example/src/example/handlers/external.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends from safir.dependencies.logger import logger_dependency from safir.metadata import get_metadata +from safir.slack.webhook import SlackRouteErrorHandler from structlog.stdlib import BoundLogger from ..config import config @@ -12,7 +13,7 @@ __all__ = ["external_router"] -external_router = APIRouter() +external_router = APIRouter(route_class=SlackRouteErrorHandler) """FastAPI router for all external handlers.""" diff --git a/project_templates/fastapi_safir_app/example/src/example/handlers/internal.py b/project_templates/fastapi_safir_app/example/src/example/handlers/internal.py index ed956e4e..c59985fc 100644 --- a/project_templates/fastapi_safir_app/example/src/example/handlers/internal.py +++ b/project_templates/fastapi_safir_app/example/src/example/handlers/internal.py @@ -10,12 +10,13 @@ from fastapi import APIRouter from safir.metadata import Metadata, get_metadata +from safir.slack.webhook import SlackRouteErrorHandler from ..config import config __all__ = ["internal_router"] -internal_router = APIRouter() +internal_router = APIRouter(route_class=SlackRouteErrorHandler) """FastAPI router for all internal handlers.""" diff --git a/project_templates/fastapi_safir_app/example/src/example/main.py b/project_templates/fastapi_safir_app/example/src/example/main.py index e649f64b..486701e5 100644 --- a/project_templates/fastapi_safir_app/example/src/example/main.py +++ b/project_templates/fastapi_safir_app/example/src/example/main.py @@ -15,6 +15,7 @@ from safir.dependencies.http_client import http_client_dependency from safir.logging import configure_logging, configure_uvicorn_logging from safir.middleware.x_forwarded import XForwardedMiddleware +from safir.slack.webhook import SlackRouteErrorHandler from .config import config from .handlers.external import external_router @@ -58,3 +59,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Add middleware. app.add_middleware(XForwardedMiddleware) + +# Configure Slack alerts. +if config.slack_webhook: + logger = structlog.get_logger("example") + SlackRouteErrorHandler.initialize( + config.slack_webhook, "example", logger + ) + logger.debug("Initialized Slack webhook") diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/config.py b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/config.py index 0c9b96fd..e94c805f 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/config.py +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/config.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import Field +from pydantic import Field, SecretStr from pydantic_settings import {% if cookiecutter.flavor != "UWS" %}BaseSettings, {% endif %}SettingsConfigDict from safir.logging import LogLevel, Profile {%- if cookiecutter.flavor == "UWS" %} @@ -18,8 +18,16 @@ class Config({% if cookiecutter.flavor == "UWS" %}UWSAppSettings{% else %}BaseSettings{% endif %}): """Configuration for {{ cookiecutter.name }}.""" + model_config = SettingsConfigDict( + env_prefix="{{ cookiecutter.name | upper | replace('-', '_') }}_", case_sensitive=False + ) + name: str = Field("{{ cookiecutter.name }}", title="Name of application") + log_level: LogLevel = Field( + LogLevel.INFO, title="Log level of the application's logger" + ) + path_prefix: str = Field( "/{{ cookiecutter.name | lower }}", title="URL prefix for application" ) @@ -28,12 +36,10 @@ class Config({% if cookiecutter.flavor == "UWS" %}UWSAppSettings{% else %}BaseSe Profile.development, title="Application logging profile" ) - log_level: LogLevel = Field( - LogLevel.INFO, title="Log level of the application's logger" - ) - - model_config = SettingsConfigDict( - env_prefix="{{ cookiecutter.name | upper | replace('-', '_') }}_", case_sensitive=False + slack_webhook: SecretStr | None = Field( + None, + title="Slack webhook for alerts", + description="If set, alerts will be posted to this Slack webhook", ) {%- if cookiecutter.flavor == "UWS" %} diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/external.py b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/external.py index 338c3083..5c403f73 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/external.py +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/external.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends from safir.dependencies.logger import logger_dependency from safir.metadata import get_metadata +from safir.slack.webhook import SlackRouteErrorHandler from structlog.stdlib import BoundLogger from ..config import config @@ -12,7 +13,7 @@ __all__ = ["external_router"] -external_router = APIRouter() +external_router = APIRouter(route_class=SlackRouteErrorHandler) """FastAPI router for all external handlers.""" diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/internal.py b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/internal.py index 8482c5f2..9c6afe5f 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/internal.py +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/internal.py @@ -10,12 +10,13 @@ from fastapi import APIRouter from safir.metadata import Metadata, get_metadata +from safir.slack.webhook import SlackRouteErrorHandler from ..config import config __all__ = ["internal_router"] -internal_router = APIRouter() +internal_router = APIRouter(route_class=SlackRouteErrorHandler) """FastAPI router for all internal handlers.""" diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/main.py b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/main.py index a0c62010..06676527 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/main.py +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/main.py @@ -11,10 +11,12 @@ from contextlib import asynccontextmanager from importlib.metadata import metadata, version +import structlog from fastapi import FastAPI from safir.dependencies.http_client import http_client_dependency from safir.logging import configure_logging, configure_uvicorn_logging from safir.middleware.x_forwarded import XForwardedMiddleware +from safir.slack.webhook import SlackRouteErrorHandler from .config import config{% if cookiecutter.flavor == "UWS" %}, uws{% endif %} from .handlers.external import external_router @@ -73,3 +75,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Install error handlers. uws.install_error_handlers(app) {%- endif %} + +# Configure Slack alerts. +if config.slack_webhook: + logger = structlog.get_logger("{{ cookiecutter.module_name }}") + SlackRouteErrorHandler.initialize( + config.slack_webhook, "{{ cookiecutter.name }}", logger + ) + logger.debug("Initialized Slack webhook") diff --git a/project_templates/technote_md/testn-000/technote.toml b/project_templates/technote_md/testn-000/technote.toml index 8aa7d521..116c4349 100644 --- a/project_templates/technote_md/testn-000/technote.toml +++ b/project_templates/technote_md/testn-000/technote.toml @@ -4,7 +4,7 @@ series_id = "TESTN" canonical_url = "https://testn-000.lsst.io" github_url = "https://github.com/lsst/testn-000" github_default_branch = "main" -date_created = 2024-11-25T21:31:10Z +date_created = 2024-11-25T21:45:02Z organization.name = "Vera C. Rubin Observatory" organization.ror = "https://ror.org/048g3cy84" license.id = "CC-BY-4.0" diff --git a/project_templates/technote_rst/testn-000/technote.toml b/project_templates/technote_rst/testn-000/technote.toml index ce19c4bf..bf045d13 100644 --- a/project_templates/technote_rst/testn-000/technote.toml +++ b/project_templates/technote_rst/testn-000/technote.toml @@ -4,7 +4,7 @@ series_id = "TESTN" canonical_url = "https://testn-000.lsst.io" github_url = "https://github.com/lsst/testn-000" github_default_branch = "main" -date_created = 2024-11-25T21:31:10Z +date_created = 2024-11-25T21:45:02Z organization.name = "Vera C. Rubin Observatory" organization.ror = "https://ror.org/048g3cy84" license.id = "CC-BY-4.0"