fix(sentry): initialize Sentry after DB settings are loaded in lifespan

Move init_sentry() from module level into the FastAPI lifespan context
manager, immediately after load_settings_from_db() completes. This
ensures that SENTRY_DSN and other Sentry settings configured via the
database admin UI are picked up on every restart.

Also update tests and docs accordingly.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 22:17:18 +00:00
parent 034876afbb
commit 5864a4ed06
3 changed files with 39 additions and 7 deletions
+4 -4
View File
@@ -48,10 +48,6 @@ SESSION_SECRET = (
settings.session_secret or "INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS" settings.session_secret or "INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS"
) )
# Initialise Sentry as early as possible so that any startup errors are captured
init_sentry()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
""" """
@@ -74,6 +70,10 @@ async def lifespan(app: FastAPI):
finally: finally:
db.close() db.close()
# Initialize Sentry after DB settings are loaded so that values configured
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
init_sentry()
# Ensure OCR language data is available (background download, non-blocking) # Ensure OCR language data is available (background download, non-blocking)
from app.utils.ocr_language_manager import ensure_ocr_languages_async from app.utils.ocr_language_manager import ensure_ocr_languages_async
+3 -3
View File
@@ -13,7 +13,7 @@ When a **Sentry DSN** is configured, every unhandled exception in the FastAPI we
``` ```
https://<public_key>@o<org_id>.ingest.sentry.io/<project_id> https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
``` ```
3. Set the DSN in your environment: 3. Set the DSN in your environment **or** via the **Settings → Observability** section of the DocuElevate admin UI:
```bash ```bash
SENTRY_DSN=https://<public_key>@o<org_id>.ingest.sentry.io/<project_id> SENTRY_DSN=https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
``` ```
@@ -31,7 +31,7 @@ When a **Sentry DSN** is configured, every unhandled exception in the FastAPI we
| `SENTRY_PROFILES_SAMPLE_RATE` | `0.0` | Fraction of profiled transactions sent to Sentry (0.0 1.0). Only active when traces > 0. | | `SENTRY_PROFILES_SAMPLE_RATE` | `0.0` | Fraction of profiled transactions sent to Sentry (0.0 1.0). Only active when traces > 0. |
| `SENTRY_SEND_DEFAULT_PII` | `false` | Attach PII (IP addresses, user agents) to events. Disable for GDPR / CCPA compliance. | | `SENTRY_SEND_DEFAULT_PII` | `false` | Attach PII (IP addresses, user agents) to events. Disable for GDPR / CCPA compliance. |
All variables can alternatively be managed through the **Settings → Observability** section of the DocuElevate admin UI. All variables can alternatively be managed through the **Settings → Observability** section of the DocuElevate admin UI. Settings stored in the database are applied before Sentry initialises on every startup, so changes made via the UI take effect after a restart without requiring any changes to environment variables or `.env` files.
--- ---
@@ -151,7 +151,7 @@ DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,
### No events appear in Sentry ### No events appear in Sentry
1. Verify the DSN is correct and the Sentry project is active. 1. Verify the DSN is correct and the Sentry project is active.
2. Check the application logs for the `Sentry initialised` message at startup. If it is absent, the DSN is not being read — confirm the environment variable name is `SENTRY_DSN`. 2. Check the application logs for the `Sentry initialised` message at startup. If it is absent, the DSN is not being read — confirm the environment variable name is `SENTRY_DSN`, or check the **Settings → Observability** section of the admin UI if you configured it there.
3. Test with `SENTRY_TRACES_SAMPLE_RATE=1.0` so that every request is sent. 3. Test with `SENTRY_TRACES_SAMPLE_RATE=1.0` so that every request is sent.
### `sentry-sdk` import error ### `sentry-sdk` import error
+32
View File
@@ -48,6 +48,7 @@ class TestLifespanEvents:
patch("app.utils.notification.init_apprise"), patch("app.utils.notification.init_apprise"),
patch("app.utils.notification.notify_startup"), patch("app.utils.notification.notify_startup"),
patch("app.utils.notification.notify_shutdown"), patch("app.utils.notification.notify_shutdown"),
patch("app.utils.sentry.init_sentry"),
): ):
# Mock database session # Mock database session
mock_db = MagicMock() mock_db = MagicMock()
@@ -74,6 +75,7 @@ class TestLifespanEvents:
patch("app.utils.notification.init_apprise"), patch("app.utils.notification.init_apprise"),
patch("app.utils.notification.notify_startup"), patch("app.utils.notification.notify_startup"),
patch("app.utils.notification.notify_shutdown"), patch("app.utils.notification.notify_shutdown"),
patch("app.utils.sentry.init_sentry"),
patch("logging.warning") as mock_warning, patch("logging.warning") as mock_warning,
): ):
mock_db = MagicMock() mock_db = MagicMock()
@@ -101,6 +103,7 @@ class TestLifespanEvents:
patch("app.utils.notification.init_apprise"), patch("app.utils.notification.init_apprise"),
patch("app.utils.notification.notify_startup"), patch("app.utils.notification.notify_startup"),
patch("app.utils.notification.notify_shutdown"), patch("app.utils.notification.notify_shutdown"),
patch("app.utils.sentry.init_sentry"),
patch("logging.error") as mock_error, patch("logging.error") as mock_error,
): ):
mock_db = MagicMock() mock_db = MagicMock()
@@ -114,6 +117,35 @@ class TestLifespanEvents:
mock_error.assert_called() mock_error.assert_called()
@pytest.mark.asyncio
async def test_lifespan_calls_init_sentry_after_db_settings_load(self):
"""Test that init_sentry is called inside lifespan after load_settings_from_db."""
from unittest.mock import call
with (
patch("app.database.init_db"),
patch("app.database.SessionLocal") as mock_session_cls,
patch("app.utils.config_loader.load_settings_from_db") as mock_load_settings,
patch("app.utils.config_validator.dump_all_settings"),
patch("app.utils.config_validator.check_all_configs", return_value={"email": [], "storage": {}}),
patch("app.utils.notification.init_apprise"),
patch("app.utils.notification.notify_startup"),
patch("app.utils.notification.notify_shutdown"),
patch("app.main.init_sentry") as mock_init_sentry,
):
mock_db = MagicMock()
mock_session_cls.return_value = mock_db
from app.main import app, lifespan
async with lifespan(app):
pass
# init_sentry must have been called exactly once during startup
mock_init_sentry.assert_called_once()
# load_settings_from_db must also have been called
mock_load_settings.assert_called_once()
@pytest.mark.unit @pytest.mark.unit
class TestExceptionHandlers: class TestExceptionHandlers: