diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2ff79327..13493113 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,8 +21,8 @@ repos: rev: 1.13.0 hooks: - id: blacken-docs - additional_dependencies: [black==22.10.0] - args: [-l, '77', -t, py39] + additional_dependencies: [black==23.1.0] + args: [-l, '76', -t, py39] - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8e1295..2f7d5307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Headline template: X.Y.Z (YYYY-MM-DD) --> +## 3.8.0 (2023-03-15) + +### New features + +- Add `safir.slack.webhook.SlackWebhookClient` and accompanying models to post a structured message to Slack via an incoming webhook. Add a `safir.slack.blockkit.SlackException` base class that can be used to create exceptions with supplemental metadata that can be sent to Slack as a formatted alert. +- Add a FastAPI route class (`safir.slack.webhook.SlackRouteErrorHandler`) that reports all uncaught exceptions to Slack using an incoming webhook. +- Add `safir.datetime.format_datetime_for_logging` to convert a `datetime` object into an easily human-readable representation. +- Add `safir.testing.slack.mock_slack_webhook` and an associated mock class to mock a Slack webhook for testing. +- Add `microseconds=True` parameter to `safir.datetime.current_datetime` to get a `datetime` object with microseconds filled in. By default, `current_datetime` suppresses the microseconds since databases often cannot store them, but there are some timestamp uses, such as error reporting, that benefit from microseconds and are never stored in databases. + ## 3.7.0 (2023-03-06) ### New features diff --git a/docs/_rst_epilog.rst b/docs/_rst_epilog.rst index 69515502..0f381205 100644 --- a/docs/_rst_epilog.rst +++ b/docs/_rst_epilog.rst @@ -15,3 +15,4 @@ .. _Click: https://click.palletsprojects.com/ .. _Uvicorn: https://www.uvicorn.org/ .. _PyPI: https://pypi.org/project/safir/ +.. _respx: https://lundberg.github.io/respx/ diff --git a/docs/api.rst b/docs/api.rst index d489c2d3..77541e3e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -54,10 +54,16 @@ API reference .. automodapi:: safir.pydantic :include-all-objects: +.. automodapi:: safir.slack.blockkit + +.. automodapi:: safir.slack.webhook + .. automodapi:: safir.testing.gcs :include-all-objects: .. automodapi:: safir.testing.kubernetes :include-all-objects: +.. automodapi:: safir.testing.slack + .. automodapi:: safir.testing.uvicorn diff --git a/docs/documenteer.toml b/docs/documenteer.toml index 7b674695..0d87e0ab 100644 --- a/docs/documenteer.toml +++ b/docs/documenteer.toml @@ -13,6 +13,7 @@ nitpick_ignore_regex = [ ['py:.*', 'kubernetes_asyncio.*'], ['py:.*', 'httpx.*'], ['py:.*', 'pydantic.*'], + ['py:.*', 'respx.*'], ['py:.*', 'starlette.*'], ] nitpick_ignore = [ diff --git a/docs/user-guide/arq.rst b/docs/user-guide/arq.rst index 2fbde2be..c2221eda 100644 --- a/docs/user-guide/arq.rst +++ b/docs/user-guide/arq.rst @@ -195,7 +195,9 @@ The `safir.dependencies.arq.arq_dependency` dependency provides your FastAPI end ) -> Dict[str, Any]: """Get metadata about a job.""" try: - job = await arq_queue.get_job_metadata(job_id, queue_name=queue_name) + job = await arq_queue.get_job_metadata( + job_id, queue_name=queue_name + ) except JobNotFound: raise HTTPException(status_code=404) diff --git a/docs/user-guide/datetime.rst b/docs/user-guide/datetime.rst index b2802f7b..89bb4a22 100644 --- a/docs/user-guide/datetime.rst +++ b/docs/user-guide/datetime.rst @@ -22,11 +22,11 @@ Getting the current date and time To get the current date and time as a `~datetime.datetime` object, use `safir.datetime.current_datetime`. -In addition to ensuring that the returned object is time zone aware and uses the UTC time zone, this function sets milliseconds to zero. +In addition to ensuring that the returned object is time zone aware and uses the UTC time zone, this function sets microseconds to zero by default. This is useful for database-based applications, since databases may or may not store milliseconds or, worse, accept non-zero milliseconds and then silently discard them. -Mixing `~datetime.datetime` objects with and without milliseconds can lead to confusing bugs, which using this function consistently can avoid. +Mixing `~datetime.datetime` objects with and without microseconds can lead to confusing bugs, which using this function consistently can avoid. -If milliseconds are needed for a particular application, this helper function is not suitable. +If microseconds are needed for a particular use (presumably one where the timestamps will never be stored in a database), pass ``microseconds=True`` as an argument. Date and time serialization =========================== @@ -64,3 +64,15 @@ To use this format as the serialized representation of any `~datetime.datetime` json_encoders = {datetime: lambda v: isodatetime(v)} Also see the Pydantic validation function `safir.pydantic.normalize_isodatetime`, discussed further at :ref:`pydantic-datetime`. + +Formatting datetimes for logging +================================ + +While the ISO 8601 format is recommended for dates that need to be read by both computers and humans, it is not ideal for humans. +The ``T`` in the middle and the trailing ``Z`` can make it look cluttered, and some timestamps (such as for error events) benefit from the precision of milliseconds. + +Safir therefore also provides `safir.datetime.format_datetime_for_logging`, which formats a `~datetime.datetime` object as ``YYYY-MM-DD HH:MM:SS[.sss]``, where the milliseconds are included only if the `~datetime.datetime` object has non-zero microseconds. +(That last rule avoids adding spurious ``.000`` strings to every formatted timestamp when the program only tracks times to second precision, such as when `~safir.datetime.current_datetime` is used.) + +As the name of the function indicates, this function should only be used when formatting dates for logging and other human display. +Dates that may need to be parsed again by another program should use `~safir.datetime.isodatetime` instead. diff --git a/docs/user-guide/index.rst b/docs/user-guide/index.rst index b90bea93..3ae21860 100644 --- a/docs/user-guide/index.rst +++ b/docs/user-guide/index.rst @@ -27,3 +27,4 @@ User guide fastapi-errors datetime uvicorn + slack-webhook diff --git a/docs/user-guide/slack-webhook.rst b/docs/user-guide/slack-webhook.rst new file mode 100644 index 00000000..39f08029 --- /dev/null +++ b/docs/user-guide/slack-webhook.rst @@ -0,0 +1,255 @@ +############################################## +Sending messages and alerts to a Slack webhook +############################################## + +It is sometimes useful for a web application to have a mechanism for reporting an urgent error or the results of a consistency audit to human administrators. +One convenient way to do this is to set up a Slack channel for that purpose and a `Slack incoming webhook `__ for posting to that channel. + +This is a write-only way of putting messages on a Slack channel. +The application cannot read messages; it can only send messages by posting them to the webhook URL. +Messages are normally formatted using Slack's `Block Kit API `__. + +Safir provides a client for posting such messages and support for using that client to post common types of alerts, such as uncaught exceptions. + +Posting a message to a Slack webhook +==================================== + +Creating a Slack webhook client +------------------------------- + +To post a message to Slack, first create a `~safir.slack.webhook.SlackWebhookClient`. +You will need to pass in the webhook URL (which should be injected into your application as a secret, since anyone who possesses the URL can post to the channel), the human-readable name of the application (used when reporting exceptions), and a structlog_ logger for reporting failures to post messages to Slack. + +.. code-block:: python + + import structlog + from safir.slack.webhook import SlackWebhookClient + + + logger = structlog.get_logger(__name__) + client = SlackWebhookClient(config.webhook_url, "App Name", logger) + +This is a simplified example. +Often the logger will instead come from a FastAPI dependency (see :ref:`logging-in-handlers`). + +Creating a Slack message +------------------------ + +Then, construct a `~safir.slack.blockkit.SlackMessage` that you want to post. +This has a main message in Slack's highly-simplified `mrkdwn variant of Markdown `__, zero or more fields, zero or more extra blocks, and zero or more attachments. + +A field is a heading and a short amount of data (normally a few words or a short line) normally used to hold supplemental information about the message. +Possible examples are the username of the user that triggered the message, a formatted time when some event happened, or the route that was being accessed. +Fields will be formatted in two columns in the order given, left to right and then top to bottom. +Text in fields is limited to 2000 characters (after formatting) and will be truncated if it is longer, but normally should be much shorter than this. +A message may have at most 10 fields. + +Longer additional data should go into an additional block. +Those blocks will be displayed in one column below the fields and main message. +Text in those fields is limited to 3000 characters (after formatting) and will be truncated if it is longer. + +Attachments are additional blocks added after the message. +Slack will automatically shorten long attachments and add a "See more" option to expand them. +Attachments are also limited to 3000 characters (after formatting). + +.. warning:: + + Slack has declared attachments "legacy" and has warned that their behavior may change in the future to make them less visible. + However, attachments are the only current supported way to collapse long fields by default with a "See more" option. + Only use attachments if you need that Slack functionality; otherwise, use blocks. + +Both fields and blocks can have either text, which is formatted in mrkdwn the same as the main message, or code, which is formatted in a code block. +If truncation is needed, text fields are truncated at the bottom and code blocks are truncated at the top. +(The code block truncation behavior is because JupyterLab failure messages have the most useful information at the bottom.) + +All text fields except the main message are marked as verbatim from Slack's perspective, which means that channel and user references will not turn into links or notifications. +The main message is also verbatim by default, but this can be disabled by passing ``verbatim=False`` +If it is disabled, so channel and user references will work as normal in Slack. +``verbatim=False`` should only be used when the message comes from trusted sources, not from user input. + +Here's an example of constructing a message: + +.. code-block:: python + + from safir.datetime import current_datetime, format_datetime_for_logging + from safir.slack.blockkit import ( + SlackCodeBlock, + SlackCodeField, + SlackTextBlock, + SlackTextField, + SlackMessage, + ) + + + now = format_datetime_for_logging(current_datetime()) + message = SlackMessage( + message="This is the main part of the message *in mrkdwn*", + fields=[ + SlackTextField(heading="Timestamp", text=now), + SlackCodeField(heading="Code", code="some code"), + ], + blocks=[SlackTextBlock(heading="Log", text="some longer log data")], + attachments=[SlackCodeBlock(heading="Errors", code="some long error")], + ) + +Posting the message to Slack +---------------------------- + +Finally, post the message to the Slack webhook: + +.. code-block:: python + + await client.post(message) + +This method will never return an error. +If posting the message to Slack fails, an exception will be logged using the logger provided when constructing the client, but the caller will not be notified. + +Reporting an exception to a Slack webhook +========================================= + +One useful thing to use a Slack webhook for is to report unexpected or worrisome exceptions. +Safir provides a base class, `~safir.slack.blockkit.SlackException`, which can be used as a parent class for your application exceptions to produce a nicely-formatted error message in Slack. + +The default `~safir.slack.blockkit.SlackException` constructor takes the username of the user who triggered the exception as an additional optional argument. +The username is also exposed as the ``user`` attribute of the class and can be set and re-raised by a calling context that knows the user. +For example, assuming that ``SomeAppException`` is a child class of `~safir.slack.blockkit.SlackException`: + +.. code-block:: python + + try: + do_something_that_may_raise() + except SomeAppException as e: + e.user = username + raise + +This same pattern can be used with additional attributes added by your derived exception class to annotate it with additional information from its call stack. + +Then, to send the exception (here, ``exc``) to Slack, do: + +.. code-block:: python + + await client.post_exception(exc) + +Under the hood, this will call the ``to_slack`` method on the exception to get a formatted Slack message. +The default implementation uses the exception message as the main Slack message and adds fields for the exception type, the time at which the exception was raised, and the username if set. +Child classes can override this method to add additional information. +For example: + +.. code-block:: python + + from safir.slack.blockkit import ( + SlackException, + SlackMessage, + SlackTextField, + ) + + + class SomeAppException(SlackException): + def __init__(self, msg: str, user: str, data: str) -> None: + super().__init__(msg, user) + self.data = data + + def to_slack(self) -> SlackMessage: + message = super().to_slack() + message.fields.append( + SlackTextField(heading="Data", text=self.data) + ) + return message + +.. warning:: + + The full exception message (although not the traceback) is sent to Slack, so it should not contain any sensitive information, security keys, or similar data. + +.. _slack-uncaught-exceptions: + +Reporting uncaught exceptions to a Slack webhook +================================================ + +The above exception reporting mechanism only works with exceptions that were caught by the application code. +Uncaught exceptions are a common problem for most web applications and indicate some unanticipated error case. +Often, all uncaught exceptions should be reported to Slack so that someone can investigate, fix the error condition, and add code to detect that error in the future. + +Safir provides a mechanism for a FastAPI app to automatically report all uncaught exceptions to Slack. +This is done through a custom route class, `~safir.slack.webhook.SlackRouteErrorHandler`, that checks every route for uncaught exceptions and reports them to Slack before re-raising them. + +If the class is not configured with a Slack webhook, it does nothing but re-raise the exception, exactly as if it were not present. +Configuring a Slack incoming webhook is therefore not a deployment requirement for the application, only something that is used if it is available. + +To configure this class, add code like the following in the same place the FastAPI app is constructed: + +.. code-block:: python + + import structlog + from safir.slack.webhook import SlackRouteErrorHandler + + + structlog.get_logger(__name__) + SlackRouteErrorHandler.initialize( + config.slack_webhook, "Application Name", logger + ) + +The arguments are the same as those to the constructor of `~safir.slack.webhook.SlackWebhookClient`. +The second argument, the application name, is used in the generated Slack message. +The logger will be used to report failures to send an alert to Slack, after which the original exception will be re-raised. + +Then, use this as a custom class for every FastAPI router whose routes should report uncaught exceptions to Slack: + +.. code-block:: python + + from fastapi import APIRouter + from safir.slack.webhook import SlackRouteErrorHandler + + + router = APIRouter(route_class=SlackRouteErrorHandler) + +Exceptions inheriting from :exc:`fastapi.HTTPException`, :exc:`fastapi.exceptions.RequestValidationError`, or :exc:`starlette.exceptions.HTTPException` will not be reported. +These exceptions have default handlers and are therefore not uncaught exceptions. + +.. warning:: + + The full exception message (although not the traceback) is sent to Slack. + Since the exception is by definition unknown, this carries some inherent risk of disclosing security-sensitive data to Slack. + If you use this feature, consider making the Slack channel to which the incoming webhook is connected private, and closely review exception handling in any code related to secrets. + +If your application has additional exceptions for which you are installing exception handlers, those exceptions should inherit from `~safir.slack.webhook.SlackIgnoredException`. +This exception class has no behavior and can be safely used as an additional parent class with other base classes. +It flags the exception for this route class so that it will not be reported to Slack. + +Testing code that uses a Slack webhook +====================================== + +The `safir.testing.slack` module provides a simple mock of a Slack webhook that accumulates every message sent to it. + +To use it, first define a fixture: + +.. code-block:: python + + import pytest + import respx + from safir.testing.slack import MockSlackWebhook, mock_slack_webhook + + + @pytest.fixture + def mock_slack(respx_mock: respx.Router) -> MockSlackWebhook: + return mock_slack_webhook(config.slack_webhook, respx_mock) + +Replace ``config.slack_webhook`` with whatever webhook configuration your application uses. +You will need to add ``respx`` as a dev dependency of your application. + +Then, in a test, use a pattern like the following: + +.. code-block:: python + + import pytest + from httpx import AsyncClient + from safir.testing.slack import MockSlackWebhook + + + @pytest.mark.asyncio + def test_something( + client: AsyncClient, mock_slack: MockSlackWebhook + ) -> None: + # Do something with client that generates Slack messages. + assert mock_slack.messages == [{...}, {...}] + +The ``url`` attribute of the `~safir.testing.slack.MockSlackWebhook` object contains the URL it was configured to mock, in case a test needs convenient access to it. diff --git a/pyproject.toml b/pyproject.toml index abb1d0ab..e5873b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,6 +132,9 @@ skip = ["docs/conf.py"] [tool.pytest.ini_options] asyncio_mode = "strict" +filterwarnings = [ + "ignore:'cgi' is deprecated:DeprecationWarning:google.cloud.storage.blob", +] python_files = [ "tests/*.py", "tests/*/*.py" @@ -143,10 +146,11 @@ disallow_incomplete_defs = true ignore_missing_imports = true local_partial_types = true no_implicit_reexport = true +plugins = [ + "pydantic.mypy", + "sqlalchemy.ext.mypy.plugin", +] strict_equality = true warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true -plugins = [ - "sqlalchemy.ext.mypy.plugin" -] diff --git a/src/safir/datetime.py b/src/safir/datetime.py index a3293a67..347f87ff 100644 --- a/src/safir/datetime.py +++ b/src/safir/datetime.py @@ -3,30 +3,89 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import Optional +from typing import Optional, overload __all__ = [ "current_datetime", + "format_datetime_for_logging", "isodatetime", "parse_isodatetime", ] -def current_datetime() -> datetime: +def current_datetime(*, microseconds: bool = False) -> datetime: """Construct a `~datetime.datetime` for the current time. + It's easy to forget to force all `~datetime.datetime` objects to be time + zone aware. This function forces UTC for all objects. + Databases do not always store microseconds in time fields, and having some - dates with microseconds and others without them can lead to bugs. It's - also easy to forget to force all `~datetime.datetime` objects to be time - zone aware. This function avoids both problems by forcing UTC and forcing - microseconds to 0. + dates with microseconds and others without them can lead to bugs, so by + default it suppresses the microseconds. + + Parameters + ---------- + microseconds + Whether to include microseconds. Consider setting this to `True` when + getting timestamps for error reporting, since granular timestamps can + help in understanding sequencing. Returns ------- datetime.datetime - The current time forced to UTC and with the microseconds field zeroed. + The current time forced to UTC and optionally with the microseconds + field zeroed. + """ + result = datetime.now(tz=timezone.utc) + if microseconds: + return result + else: + return result.replace(microsecond=0) + + +@overload +def format_datetime_for_logging(timestamp: datetime) -> str: + ... + + +@overload +def format_datetime_for_logging(timestamp: None) -> None: + ... + + +def format_datetime_for_logging( + timestamp: Optional[datetime], +) -> Optional[str]: + """Format a datetime for logging and human readabilty. + + Parameters + ---------- + timestamp + Object to format. Must be in UTC or timezone-naive (in which case it's + assumed to be in UTC). + + Returns + ------- + str or None + The datetime in format ``YYYY-MM-DD HH:MM:SS[.sss]`` with milliseconds + added if and only if the microseconds portion of ``timestamp`` is not + 0. There will be no ``T`` separator or time zone information. + + Raises + ------ + ValueError + Raised if the argument is in a time zone other than UTC. """ - return datetime.now(tz=timezone.utc).replace(microsecond=0) + if timestamp: + if timestamp.tzinfo not in (None, timezone.utc): + raise ValueError("Datetime {timestamp} not in UTC") + if timestamp.microsecond: + result = timestamp.isoformat(sep=" ", timespec="milliseconds") + else: + result = timestamp.isoformat(sep=" ", timespec="seconds") + return result.split("+")[0] + else: + return None def isodatetime(timestamp: datetime) -> str: diff --git a/src/safir/slack/__init__.py b/src/safir/slack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/safir/slack/blockkit.py b/src/safir/slack/blockkit.py new file mode 100644 index 00000000..3b2f3ed2 --- /dev/null +++ b/src/safir/slack/blockkit.py @@ -0,0 +1,363 @@ +"""Slack Block Kit message models.""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from datetime import datetime +from typing import Any, ClassVar, Dict, List, Optional + +from pydantic import BaseModel, validator + +from safir.datetime import current_datetime, format_datetime_for_logging + +__all__ = [ + "SlackBaseBlock", + "SlackBaseField", + "SlackCodeBlock", + "SlackCodeField", + "SlackException", + "SlackMessage", + "SlackTextBlock", + "SlackTextField", +] + + +class SlackBaseBlock(BaseModel, metaclass=ABCMeta): + """Base class for any Slack Block Kit block.""" + + @abstractmethod + def to_slack(self) -> Dict[str, Any]: + """Convert to a Slack Block Kit block. + + Returns + ------- + dict + A Slack Block Kit block suitable for including in the ``fields`` + or ``text`` section of a ``blocks`` element. + """ + + +class SlackTextBlock(SlackBaseBlock): + """A component of a Slack message with a heading and a text body. + + If the formatted output is longer than 3000 characters, it will be + truncated to avoid the strict uppper limit imposed by Slack. + """ + + heading: str + """Heading of the field (shown in bold).""" + + text: str + """Text of the field as normal text. + + This is always marked as vertabim, so channel mentions or @-mentions of + users will not be treated as special. + """ + + max_formatted_length: ClassVar[int] = 3000 + """Maximum length of formatted output, imposed by Slack. + + Intended to be overridden by child classes that need to impose different + maximum lengths. + """ + + def to_slack(self) -> Dict[str, Any]: + """Convert to a Slack Block Kit block. + + Returns + ------- + dict + A Slack Block Kit block suitable for including in the ``fields`` + or ``text`` section of a ``blocks`` element. + """ + heading = f"*{self.heading}*\n" + max_length = self.max_formatted_length - len(heading) + body = _format_and_truncate_at_end(self.text, max_length) + return {"type": "mrkdwn", "text": heading + body, "verbatim": True} + + +class SlackCodeBlock(SlackBaseBlock): + """A component of a Slack message with a heading and a code block. + + If the formatted output is longer than 3000 characters, it will be + truncated to avoid the strict upper limit imposed by Slack. + """ + + heading: str + """Heading of the field (shown in bold).""" + + code: str + """Text of the field as a code block.""" + + max_formatted_length: ClassVar[int] = 3000 + """Maximum length of formatted output, imposed by Slack. + + Intended to be overridden by child classes that need to impose different + maximum lengths. + """ + + def to_slack(self) -> Dict[str, Any]: + """Convert to a Slack Block Kit block. + + Returns + ------- + dict + A Slack Block Kit block suitable for including in the ``fields`` + or ``text`` section of a ``blocks`` element. + """ + heading = f"*{self.heading}*\n" + extra_needed = len(heading) + 8 # ```\n\n``` + max_length = self.max_formatted_length - extra_needed + code = _format_and_truncate_at_start(self.code, max_length) + text = f"{heading}```\n{code}\n```" + return {"type": "mrkdwn", "text": text, "verbatim": True} + + +class SlackBaseField(SlackBaseBlock): + """Base class for Slack Block Kit blocks for the ``fields`` section.""" + + max_formatted_length = 2000 + + +class SlackTextField(SlackTextBlock, SlackBaseField): + """One field in a Slack message with a heading and text body. + + Intended for use in the ``fields`` portion of a Block Kit message. If the + formatted output is longer than 2000 characters, it will be truncated to + avoid the strict upper limit imposed by Slack. + """ + + +class SlackCodeField(SlackCodeBlock, SlackBaseField): + """An attachment in a Slack message with a heading and text body. + + Intended for use in the ``fields`` portion of a Block Kit message. If + the formatted output is longer than 2000 characters, it will be truncated + to avoid the strict upper limit imposed by Slack. + """ + + +class SlackMessage(BaseModel): + """Message to post to Slack. + + The ``message`` attribute will be the initial part of the message. + + All fields in ``fields`` will be shown below that message, formatted in + two columns. Order of ``fields`` is preserved; they will be laid out left + to right and then top to bottom in the order given. Then, ``blocks`` will + be added, if any, in one column below the fields. Finally, ``attachments`` + will be added to the end as attachments, which get somewhat different + formatting (for example, long attachments are collapsed by default). + + At most ten elements are allowed in ``fields``. They should be used for + short information, generally a single half-line at most. Longer + information should go into ``blocks`` or ``attachments``. + """ + + message: str + """Main part of the message.""" + + verbatim: bool = True + """Whether the main part of the message should be marked verbatim. + + Verbatim messages in Slack don't expand channel references or create user + notifications. This is the default, but can be set to `False` to allow + any such elements in the message to be recognized by Slack. Do not set + this to `False` with untrusted input. + """ + + fields: List[SlackBaseField] = [] + """Short key/value fields to include in the message (at most 10).""" + + blocks: List[SlackBaseBlock] = [] + """Additional text blocks to include in the message (after fields).""" + + attachments: List[SlackBaseBlock] = [] + """Longer sections to include as attachments. + + Notes + ----- + Slack has marked attachments as legacy and warns that future changes may + reduce their visibility or utility. Unfortunately, there is no other way + to attach possibly-long text where Slack will hide long content by default + but allow the user to expand it. We therefore continue to use attachments + for long text for want of a better alternative. + """ + + @validator("fields") + def _validate_fields(cls, v: List[SlackBaseField]) -> List[SlackBaseField]: + """Check constraints on fields. + + Slack imposes a maximum of 10 items in a ``fields`` array. Also ensure + that no fields are actually attachments, since in that case they may + not be truncated to the correct length. (The type system we're using + doesn't allow Pydantic to check this directly.) + """ + if len(v) > 10: + msg = f"Slack does not allow more than 10 fields ({len(v)} seen)" + raise ValueError(msg) + return v + + def to_slack(self) -> Dict[str, Any]: + """Convert to a Slack Block Kit message. + + Returns + ------- + dict + A Slack Block Kit data structure suitable for serializing to + JSON and sending to Slack. + """ + attachments = [ + {"type": "section", "text": a.to_slack()} for a in self.attachments + ] + message = _format_and_truncate_at_end(self.message, 3000) + blocks: list[dict[str, Any]] = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": message, + "verbatim": self.verbatim, + }, + } + ] + fields = [f.to_slack() for f in self.fields] + if fields: + blocks.append({"type": "section", "fields": fields}) + blocks.extend( + [{"type": "section", "text": b.to_slack()} for b in self.blocks] + ) + result: dict[str, Any] = {"blocks": blocks} + if attachments: + result["attachments"] = [{"blocks": attachments}] + elif fields or self.blocks: + result["blocks"].append({"type": "divider"}) + return result + + +class SlackException(Exception): + """Parent class of exceptions that can be reported to Slack. + + Intended to be subclassed. Subclasses may wish to override the + ``to_slack`` method. + + Parameters + ---------- + message + Exception string value, which is the default Slack message. + user + Identity of user triggering the exception, if known. + failed_at + When the exception happened. Omit to use the current time. + """ + + def __init__( + self, + message: str, + user: Optional[str] = None, + *, + failed_at: Optional[datetime] = None, + ) -> None: + self.user = user + if failed_at: + self.failed_at = failed_at + else: + self.failed_at = current_datetime(microseconds=True) + super().__init__(message) + + def to_slack(self) -> SlackMessage: + """Format the exception as a Slack message. + + This is the generic version that only reports the text of the + exception and the base fields. Child exceptions may want to override + it to add more metadata. + + Returns + ------- + SlackMessage + Slack message suitable for posting with `SlackClient`. + """ + failed_at = format_datetime_for_logging(self.failed_at) + fields = [ + SlackTextField(heading="Exception type", text=type(self).__name__), + SlackTextField(heading="Failed at", text=failed_at), + ] + if self.user: + fields.append(SlackTextField(heading="User", text=self.user)) + return SlackMessage(message=str(self), fields=fields) + + +def _format_and_truncate_at_end(string: str, max_length: int) -> str: + """Format a string for Slack, truncating at the end. + + Slack prohibits text blocks longer than a varying number of characters + depending on where they are in the message. If this constraint is not met, + the whole mesage is rejected with an HTTP error. Truncate a potentially + long message at the end. + + Parameters + ---------- + string + String to truncate. + max_length + Maximum allowed length. + + Returns + ------- + str + The truncated string with special characters escaped. + """ + string = ( + string.strip() + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + if len(string) <= max_length: + return string + truncated = "\n... truncated ..." + last_newline = string.rfind("\n", 0, max_length - len(truncated)) + if last_newline == -1: + return string[: max_length - len(truncated)] + truncated + else: + return string[:last_newline] + truncated + + +def _format_and_truncate_at_start(string: str, max_length: int) -> str: + """Format a string for Slack, truncating at the start. + + Slack prohibits text blocks longer than a varying number of characters + depending on where they are in the message. If this constraint is not met, + the whole mesage is rejected with an HTTP error. Truncate a potentially + long message at the start. Use this for tracebacks and similar + + Parameters + ---------- + string + String to truncate. + max_length + Maximum allowed length. + + Returns + ------- + str + The truncated string with special characters escaped. + """ + string = ( + string.strip() + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + length = len(string) + if length <= max_length: + return string + truncated = "... truncated ...\n" + lines = string.split("\n") + if len(lines) == 1: + start = length - max_length + len(truncated) + return truncated + string[start:] + while length > max_length - len(truncated): + line = lines.pop(0) + length -= len(line) + 1 + return truncated + "\n".join(lines) diff --git a/src/safir/slack/webhook.py b/src/safir/slack/webhook.py new file mode 100644 index 00000000..ecb2273d --- /dev/null +++ b/src/safir/slack/webhook.py @@ -0,0 +1,213 @@ +"""Send messages to Slack.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any, ClassVar, Optional + +from fastapi import HTTPException, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute +from starlette.exceptions import HTTPException as StarletteHTTPException +from structlog.stdlib import BoundLogger + +from ..datetime import current_datetime, format_datetime_for_logging +from ..dependencies.http_client import http_client_dependency +from .blockkit import ( + SlackCodeBlock, + SlackException, + SlackMessage, + SlackTextField, +) + +__all__ = [ + "SlackIgnoredException", + "SlackRouteErrorHandler", + "SlackWebhookClient", +] + + +class SlackIgnoredException(Exception): + """Parent class for exceptions that should not be reported to Slack. + + This exception has no built-in behavior or meaning except to suppress + Slack notifications if it is thrown uncaught. Application exceptions that + should not result in a Slack alert (because, for example, they're intended + to be caught by exception handlers) should inherit from this class. + """ + + +class SlackWebhookClient: + """Send messages to a Slack webhook. + + Provides a simple Slack client to publish structured messages to a Slack + channel via an incoming webhook using the Block Kit API. This is a + write-only client and cannot be used for prompting or more complex apps. + + Parameters + ---------- + hook_url + URL of the Slack incoming webhook to use for publishing messages. + application + Name of the application reporting an error, used by the error + reporting methods. + logger + Logger to which to report errors sending messages to Slack. + """ + + def __init__( + self, hook_url: str, application: str, logger: BoundLogger + ) -> None: + self._hook_url = hook_url + self._application = application + self._logger = logger + + async def post(self, message: SlackMessage) -> None: + """Post a message to Slack. + + Any exceptions encountered while posting the message will be logged + but not raised. From the perspective of the caller, the message will + silently disappear. + + Parameters + ---------- + message + Message to post. + """ + self._logger.debug("Sending message to Slack") + body = message.to_slack() + try: + client = await http_client_dependency() + r = await client.post(self._hook_url, json=body) + r.raise_for_status() + except Exception: + msg = "Posting Slack message failed" + self._logger.exception(msg, message=body) + + async def post_exception(self, exc: SlackException) -> None: + """Post an alert to Slack about an exception. + + This method intentionally does not provide a way to include the + traceback, since it's hard to ensure that the traceback is entirely + free of secrets or other information that should not be disclosed on + Slack. Only the exception message is reported. + + Parameters + ---------- + exc + The exception to report. + """ + message = exc.to_slack() + message.message = f"Error in {self._application}: {message.message}" + await self.post(message) + + async def post_uncaught_exception(self, exc: Exception) -> None: + """Post an alert to Slack about an uncaught webapp exception. + + Parameters + ---------- + exc + The exception to report. + """ + if isinstance(exc, SlackException): + message = exc.to_slack() + msg = f"Uncaught exception in {self._application}: {str(exc)}" + message.message = msg + else: + date = format_datetime_for_logging(current_datetime()) + name = type(exc).__name__ + error = f"{name}: {str(exc)}" + message = SlackMessage( + message=f"Uncaught exception in {self._application}", + fields=[ + SlackTextField(heading="Exception type", text=name), + SlackTextField(heading="Failed at", text=date), + ], + blocks=[SlackCodeBlock(heading="Exception", code=error)], + ) + await self.post(message) + + +class SlackRouteErrorHandler(APIRoute): + """Custom `fastapi.routing.APIRoute` that reports exceptions to Slack. + + Dynamically wrap FastAPI route handlers in an exception handler that + reports uncaught exceptions (other than :exc:`fastapi.HTTPException`, + :exc:`fastapi.exceptions.RequestValidationError`, + :exc:`starlette.exceptions.HTTPException`, and exceptions inheriting from + `SlackIgnoredException`) to Slack. + + This class must be initialized by calling its `initialize` method to send + alerts. Until that has been done, it will silently do nothing. + + Examples + -------- + Specify this class when creating a router. All uncaught exceptions from + handlers managed by that router will be reported to Slack, if Slack alerts + are configured. + + .. code-block:: python + + router = APIRouter(route_class=SlackRouteErrorHandler) + + Notes + ----- + Based on `this StackOverflow question + `__. + """ + + _IGNORED_EXCEPTIONS = ( + HTTPException, + RequestValidationError, + StarletteHTTPException, + SlackIgnoredException, + ) + """Uncaught exceptions that should not be sent to Slack.""" + + _alert_client: ClassVar[Optional[SlackWebhookClient]] = None + """Global Slack alert client used by `SlackRouteErrorHandler`. + + Initialize with `initialize`. This object caches the alert confguration + and desired logger for the use of `SlackRouteErrorHandler` as a + process-global variable, since the route handler only has access to the + incoming request and global variables. + """ + + @classmethod + def initialize( + cls, hook_url: str, application: str, logger: BoundLogger + ) -> None: + """Configure Slack alerting. + + Until this function is called, all Slack alerting for uncaught + exceptions will be disabled. + + Parameters + ---------- + hook_url + The URL of the incoming webhook to use to publish the message. + application + Name of the application reporting an error. + logger + Logger to which to report errors sending messages to Slack. + """ + cls._alert_client = SlackWebhookClient(hook_url, application, logger) + + def get_route_handler( + self, + ) -> Callable[[Request], Coroutine[Any, Any, Response]]: + """Wrap route handler with an exception handler.""" + original_route_handler = super().get_route_handler() + + async def wrapped_route_handler(request: Request) -> Response: + try: + return await original_route_handler(request) + except Exception as e: + if isinstance(e, self._IGNORED_EXCEPTIONS): + raise + if not self._alert_client: + raise + await self._alert_client.post_uncaught_exception(e) + raise + + return wrapped_route_handler diff --git a/src/safir/testing/slack.py b/src/safir/testing/slack.py new file mode 100644 index 00000000..4c16f290 --- /dev/null +++ b/src/safir/testing/slack.py @@ -0,0 +1,97 @@ +"""Mock Slack server for testing Slack messaging.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import respx +from httpx import Request, Response + +__all__ = ["MockSlackWebhook", "mock_slack_webhook"] + + +class MockSlackWebhook: + """Represents a Slack incoming webhook and remembers what was posted. + + Attributes + ---------- + messages + Messages that have been posted to the webhook so far. + url + URL that the mock has been configured to listen on. + """ + + def __init__(self, url: str) -> None: + self.url = url + self.messages: List[Dict[str, Any]] = [] + + def post_webhook(self, request: Request) -> Response: + """Callback called whenever a Slack message is posted. + + The provided message is stored in the messages attribute. + + Parameters + ---------- + request + Incoming request. + + Returns + ------- + httpx.Response + Always returns a 201 response. + """ + self.messages.append(json.loads(request.content.decode())) + return Response(201) + + +def mock_slack_webhook( + hook_url: str, respx_mock: respx.Router +) -> MockSlackWebhook: + """Set up a mocked Slack server. + + Parameters + ---------- + hook_url + URL for the Slack incoming webhook to mock. + respx_mock + The mock router. + + Returns + ------- + MockSlackWebhook + The mock Slack webhook API object. + + Examples + -------- + To set up this mock, put a fixture in :file:`conftest.py` similar to the + following: + + .. code-block:: python + + import pytest + import respx + from safir.testing.slack import MockSlackWebhook, mock_slack_webhook + + + @pytest.fixture + def mock_slack( + config: Config, respx_mock: respx.Router + ) -> MockWebhookSlack: + return mock_slack_webhook(config.slack_webhook, respx_mock) + + This uses respx_ to mock the Slack webhook URL obtained from the + configuration of the application under test. Use it in a test as follows: + + .. code-block:: python + + @pytest.mark.asyncio + def test_something( + client: AsyncClient, mock_slack: MockSlackWebhook + ) -> None: + # Do something with client that generates Slack messages. + assert mock_slack.messages == [{...}, {...}] + """ + mock = MockSlackWebhook(hook_url) + respx_mock.post(hook_url).mock(side_effect=mock.post_webhook) + return mock diff --git a/tests/conftest.py b/tests/conftest.py index 5bc51da1..660accf2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,10 @@ from typing import Iterator import pytest +import respx from safir.testing.gcs import MockStorageClient, patch_google_storage +from safir.testing.slack import MockSlackWebhook, mock_slack_webhook @pytest.fixture @@ -15,3 +17,8 @@ def mock_gcs() -> Iterator[MockStorageClient]: yield from patch_google_storage( expected_expiration=timedelta(hours=1), bucket_name="some-bucket" ) + + +@pytest.fixture +def mock_slack(respx_mock: respx.Router) -> MockSlackWebhook: + return mock_slack_webhook("https://example.com/slack", respx_mock) diff --git a/tests/database_test.py b/tests/database_test.py index 322b163d..78a15e50 100644 --- a/tests/database_test.py +++ b/tests/database_test.py @@ -183,7 +183,7 @@ def test_datetime() -> None: with pytest.raises(ValueError): datetime_to_db(tz_naive) - tz_local = datetime.now().astimezone(timezone(timedelta(hours=1))) + tz_local = datetime.now(tz=timezone(timedelta(hours=1))) with pytest.raises(ValueError): datetime_to_db(tz_local) with pytest.raises(ValueError): diff --git a/tests/datetime_test.py b/tests/datetime_test.py index f2b876af..d554ec81 100644 --- a/tests/datetime_test.py +++ b/tests/datetime_test.py @@ -6,7 +6,12 @@ import pytest -from safir.datetime import current_datetime, isodatetime, parse_isodatetime +from safir.datetime import ( + current_datetime, + format_datetime_for_logging, + isodatetime, + parse_isodatetime, +) def test_current_datetime() -> None: @@ -16,6 +21,14 @@ def test_current_datetime() -> None: now = datetime.now(tz=timezone.utc) assert now - timedelta(seconds=2) <= time <= now + time = current_datetime(microseconds=True) + if not time.microsecond: + time = current_datetime(microseconds=True) + assert time.microsecond != 0 + assert time.tzinfo == timezone.utc + now = datetime.now(tz=timezone.utc) + assert now - timedelta(seconds=2) <= time <= now + def test_isodatetime() -> None: time = datetime.fromisoformat("2022-09-16T12:03:45+00:00") @@ -33,3 +46,22 @@ def test_parse_isodatetime() -> None: with pytest.raises(ValueError): parse_isodatetime("2022-09-16T12:03:45+00:00") + + +def test_format_datetime_for_logging() -> None: + time = datetime.fromisoformat("2022-09-16T12:03:45+00:00") + assert format_datetime_for_logging(time) == "2022-09-16 12:03:45" + + # Test with milliseconds, allowing for getting extremely unlucky and + # having no microseconds. Getting unlucky twice seems impossible, so we'll + # fail in that case rather than loop. + now = datetime.now(tz=timezone.utc) + if not now.microsecond: + now = datetime.now(tz=timezone.utc) + milliseconds = int(now.microsecond / 1000) + expected = now.strftime("%Y-%m-%d %H:%M:%S") + f".{milliseconds:03n}" + assert format_datetime_for_logging(now) == expected + + time = datetime.now(tz=timezone(timedelta(hours=1))) + with pytest.raises(ValueError): + format_datetime_for_logging(time) diff --git a/tests/slack/__init__.py b/tests/slack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/slack/blockkit_test.py b/tests/slack/blockkit_test.py new file mode 100644 index 00000000..5ca29826 --- /dev/null +++ b/tests/slack/blockkit_test.py @@ -0,0 +1,232 @@ +"""Test Slack client.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from safir.slack.blockkit import ( + SlackCodeBlock, + SlackCodeField, + SlackMessage, + SlackTextBlock, + SlackTextField, +) + + +def test_message() -> None: + message = SlackMessage( + message="This is some *Slack message* \n ", + fields=[ + SlackTextField(heading="Some text", text="Value of the field "), + SlackCodeField(heading="Some code", code="Here is\nthe code\n"), + ], + blocks=[SlackTextBlock(heading="Log", text="Some\nlong\nlog")], + attachments=[ + SlackCodeBlock(heading="Backtrace", code="Some\nbacktrace"), + SlackTextBlock(heading="Essay", text="Blah blah blah"), + ], + ) + assert message.to_slack() == { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is some *Slack message*", + "verbatim": True, + }, + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Some text*\nValue of the field", + "verbatim": True, + }, + { + "type": "mrkdwn", + "text": ("*Some code*\n```\nHere is\nthe code\n```"), + "verbatim": True, + }, + ], + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Log*\nSome\nlong\nlog", + "verbatim": True, + }, + }, + ], + "attachments": [ + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ("*Backtrace*\n```\nSome\nbacktrace\n```"), + "verbatim": True, + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Essay*\nBlah blah blah", + "verbatim": True, + }, + }, + ] + } + ], + } + + message = SlackMessage(message="Single line message", verbatim=False) + assert message.to_slack() == { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Single line message", + "verbatim": False, + }, + } + ] + } + + message = SlackMessage( + message="Message with & one `field`", + fields=[SlackTextField(heading="Something", text="Blah")], + ) + assert message.to_slack() == { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Message with <special> & one `field`", + "verbatim": True, + }, + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Something*\nBlah", + "verbatim": True, + }, + ], + }, + {"type": "divider"}, + ] + } + + message = SlackMessage( + message="Message with one block", + blocks=[SlackTextBlock(heading="Something", text="Blah")], + ) + assert message.to_slack() == { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Message with one block", + "verbatim": True, + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Something*\nBlah", + "verbatim": True, + }, + }, + {"type": "divider"}, + ] + } + + +def test_validation() -> None: + """Test errors caught by validation.""" + fields = [SlackTextField(heading="Something", text="foo")] * 11 + message = SlackMessage(message="Ten fields", fields=fields[:10]) + assert len(message.fields) == 10 + with pytest.raises(ValidationError): + SlackMessage(message="Eleven fields", fields=fields) + + +def test_block_truncation() -> None: + """Test truncating attachments at Slack limits.""" + block = SlackTextBlock(heading="Something", text="a" * 3000) + length = 3000 - len("*Something*\n\n... truncated ...") + assert block.to_slack()["text"] == ( + "*Something*\n" + "a" * length + "\n... truncated ..." + ) + + block = SlackTextBlock(heading="Something", text="abcde\n" * 500) + length = int((3001 - len("*Something*\n\n... truncated ...")) / 6) + assert block.to_slack()["text"] == ( + "*Something*\n" + "abcde\n" * length + "... truncated ..." + ) + + cblock = SlackCodeBlock(heading="Else", code="a" * 3000) + length = 3000 - len("*Else*\n```\n... truncated ...\n\n```") + assert cblock.to_slack()["text"] == ( + "*Else*\n```\n... truncated ...\n" + "a" * length + "\n```" + ) + + cblock = SlackCodeBlock(heading="Else", code="abcde\n" * 500) + length = int((3001 - len("*Else*\n```\n... truncated ...\n\n```")) / 6) + assert cblock.to_slack()["text"] == ( + "*Else*\n```\n... truncated ...\n" + + ("abcde\n" * length).strip() + + "\n```" + ) + + +def test_field_truncation() -> None: + """Test truncating fields at Slack limits.""" + field = SlackTextField(heading="Something", text="a" * 2000) + length = 2000 - len("*Something*\n\n... truncated ...") + assert field.to_slack()["text"] == ( + "*Something*\n" + "a" * length + "\n... truncated ..." + ) + + field = SlackTextField(heading="Something", text="abcdefg\n" * 250) + length = int((2001 - len("*Something*\n\n... truncated ...")) / 8) + assert field.to_slack()["text"] == ( + "*Something*\n" + "abcdefg\n" * length + "... truncated ..." + ) + + cfield = SlackCodeField(heading="Else", code="a" * 2000) + length = 2000 - len("*Else*\n```\n... truncated ...\n\n```") + assert cfield.to_slack()["text"] == ( + "*Else*\n```\n... truncated ...\n" + "a" * length + "\n```" + ) + + cfield = SlackCodeField(heading="Else", code="abcdefg\n" * 250) + length = int((2001 - len("*Else*\n```\n... truncated ...\n\n```")) / 8) + assert cfield.to_slack()["text"] == ( + "*Else*\n```\n... truncated ...\n" + + ("abcdefg\n" * length).strip() + + "\n```" + ) + + +def test_message_truncation() -> None: + """Text truncating the main part of a Slack message.""" + message = SlackMessage(message="a" * 3000) + assert message.to_slack()["blocks"][0]["text"]["text"] == "a" * 3000 + message = SlackMessage(message="a" * 3001) + length = 3000 - len("\n... truncated ...") + assert message.to_slack()["blocks"][0]["text"]["text"] == ( + "a" * length + "\n... truncated ..." + ) diff --git a/tests/slack/webhook_test.py b/tests/slack/webhook_test.py new file mode 100644 index 00000000..814af3cb --- /dev/null +++ b/tests/slack/webhook_test.py @@ -0,0 +1,250 @@ +"""Test Slack client.""" + +from __future__ import annotations + +from unittest.mock import ANY + +import pytest +import structlog +from fastapi import APIRouter, FastAPI +from httpx import ASGITransport, AsyncClient + +from safir.datetime import current_datetime, format_datetime_for_logging +from safir.slack.blockkit import SlackException, SlackMessage +from safir.slack.webhook import ( + SlackIgnoredException, + SlackRouteErrorHandler, + SlackWebhookClient, +) +from safir.testing.slack import MockSlackWebhook + + +@pytest.mark.asyncio +async def test_post(mock_slack: MockSlackWebhook) -> None: + message = SlackMessage(message="Some random message") + logger = structlog.get_logger(__file__) + client = SlackWebhookClient(mock_slack.url, "App", logger) + await client.post(message) + assert mock_slack.messages == [ + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Some random message", + "verbatim": True, + }, + } + ] + } + ] + + +@pytest.mark.asyncio +async def test_post_exception(mock_slack: MockSlackWebhook) -> None: + logger = structlog.get_logger(__file__) + client = SlackWebhookClient(mock_slack.url, "App", logger) + + exc = SlackException("Some exception message") + await client.post_exception(exc) + assert mock_slack.messages == [ + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Error in App: Some exception message", + "verbatim": True, + }, + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Exception type*\nSlackException", + "verbatim": True, + }, + { + "type": "mrkdwn", + "text": ANY, + "verbatim": True, + }, + ], + }, + {"type": "divider"}, + ] + } + ] + mock_slack.messages = [] + + class TestException(SlackException): + pass + + now = current_datetime() + now_formatted = format_datetime_for_logging(now) + exc = TestException("Blah blah blah", "username", failed_at=now) + await client.post_exception(exc) + assert mock_slack.messages == [ + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Error in App: Blah blah blah", + "verbatim": True, + }, + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Exception type*\nTestException", + "verbatim": True, + }, + { + "type": "mrkdwn", + "text": f"*Failed at*\n{now_formatted}", + "verbatim": True, + }, + { + "type": "mrkdwn", + "text": "*User*\nusername", + "verbatim": True, + }, + ], + }, + {"type": "divider"}, + ] + } + ] + + +@pytest.mark.asyncio +async def test_route_handler(mock_slack: MockSlackWebhook) -> None: + """Test Slack alerts for uncaught exceptions.""" + router = APIRouter(route_class=SlackRouteErrorHandler) + + class SomeAppError(SlackIgnoredException): + pass + + class OtherAppError(SlackException): + pass + + @router.get("/exception") + async def get_exception() -> None: + raise ValueError("Test exception") + + @router.get("/ignored") + async def get_ignored() -> None: + raise SomeAppError("Test exception") + + @router.get("/known") + async def get_known() -> None: + raise OtherAppError("Slack exception") + + app = FastAPI() + app.include_router(router) + + # We need a custom httpx configuration to disable raising server + # exceptions so that we can inspect the resulting error handling. + transport = ASGITransport( + app=app, # type: ignore[arg-type] + raise_app_exceptions=False, + ) + + # Run the test. + base_url = "https://example.com" + async with AsyncClient(transport=transport, base_url=base_url) as client: + r = await client.get("/exception") + assert r.status_code == 500 + + # Slack alerting has not been configured yet, so nothing should be + # posted to Slack. + assert mock_slack.messages == [] + + # Configure Slack alerting and raise an ignored exception. There + # should still be nothing posted to Slack. + logger = structlog.get_logger(__file__) + SlackRouteErrorHandler.initialize(mock_slack.url, "App", logger) + r = await client.get("/ignored") + assert r.status_code == 500 + assert mock_slack.messages == [] + + # But now raising another exception should result in a Slack message. + r = await client.get("/exception") + assert r.status_code == 500 + + # And raising an exception that inherits from SlackException should + # result in nicer formatting. + r = await client.get("/known") + assert r.status_code == 500 + + # Check that the Slack alert was what we expected. + assert mock_slack.messages == [ + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Uncaught exception in App", + "verbatim": True, + }, + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Exception type*\nValueError", + "verbatim": True, + }, + {"type": "mrkdwn", "text": ANY, "verbatim": True}, + ], + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + "*Exception*\n```\nValueError: Test exception\n```" + ), + "verbatim": True, + }, + }, + {"type": "divider"}, + ], + }, + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ("Uncaught exception in App: Slack exception"), + "verbatim": True, + }, + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Exception type*\nOtherAppError", + "verbatim": True, + }, + {"type": "mrkdwn", "text": ANY, "verbatim": True}, + ], + }, + {"type": "divider"}, + ], + }, + ] + assert mock_slack.messages[0]["blocks"][1]["fields"][1]["text"].startswith( + "*Failed at*\n" + )