Merge pull request #565 from christianlouis/copilot/add-sentry-integration

feat(observability): integrate Sentry for error tracking and performance monitoring
This commit is contained in:
Christian Krakau-Louis
2026-03-08 22:24:13 +01:00
committed by GitHub
10 changed files with 681 additions and 2 deletions
+20
View File
@@ -490,3 +490,23 @@ EMBEDDING_MAX_TOKENS=8000
# ZAMMAD_FORM_ENABLED=false
# Support e-mail address displayed on the Help Center page.
# SUPPORT_EMAIL=support@example.com
# **Observability Sentry Error & Performance Monitoring**
# Sentry DSN obtain from https://sentry.io (Project → Settings → Client Keys).
# Leave commented out (or set to empty) to disable Sentry entirely.
# SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
#
# Environment label shown in the Sentry dashboard (e.g. development / staging / production).
# SENTRY_ENVIRONMENT=production
#
# Fraction of requests to capture for performance tracing (0.01.0).
# 0.0 disables tracing; 1.0 captures every request. Default: 0.1 (10 %).
# SENTRY_TRACES_SAMPLE_RATE=0.1
#
# Fraction of profiled transactions to send to Sentry (0.01.0).
# Profiling is only active when SENTRY_TRACES_SAMPLE_RATE > 0. Default: 0.0 (disabled).
# SENTRY_PROFILES_SAMPLE_RATE=0.0
#
# Attach PII (IP addresses, user agents) to Sentry events.
# Disable (default) to stay GDPR/CCPA compliant.
# SENTRY_SEND_DEFAULT_PII=false
+9 -1
View File
@@ -1,7 +1,7 @@
# app/celery_app.py
from celery import Celery
from celery.signals import task_failure
from celery.signals import task_failure, worker_ready
from app.config import settings
@@ -22,6 +22,14 @@ celery.conf.task_routes = {
}
@worker_ready.connect
def init_sentry_on_worker_ready(**kwargs):
"""Initialise Sentry SDK in the Celery worker process."""
from app.utils.sentry import init_sentry
init_sentry(integrations_extra=["celery"])
@task_failure.connect
def task_failure_handler(
sender=None, task_id=None, exception=None, args=None, kwargs=None, traceback=None, einfo=None, **kw
+44
View File
@@ -941,6 +941,50 @@ class Settings(BaseSettings):
description="Support e-mail address displayed on the Help Center page.",
)
# ---------------------------------------------------------------------------
# Observability Sentry error & performance monitoring
# ---------------------------------------------------------------------------
sentry_dsn: Optional[str] = Field(
default=None,
description=(
"Sentry Data Source Name (DSN). When set, error reporting and "
"performance tracing are enabled automatically. Leave blank (or unset) "
"to disable Sentry entirely."
),
)
sentry_environment: str = Field(
default="production",
description=(
"Environment tag sent to Sentry (e.g. 'development', 'staging', 'production'). "
"Helps you filter events in the Sentry dashboard."
),
)
sentry_traces_sample_rate: float = Field(
default=0.1,
description=(
"Fraction of transactions to capture for performance monitoring (0.01.0). "
"Set to 0.0 to disable tracing, 1.0 to capture every transaction. "
"Values above 0 may increase Sentry quota usage."
),
)
sentry_profiles_sample_rate: float = Field(
default=0.0,
description=(
"Fraction of profiled transactions to send to Sentry (0.01.0). "
"Profiling is only active when traces_sample_rate > 0. "
"Defaults to 0.0 (disabled) to minimise overhead."
),
)
sentry_send_default_pii: bool = Field(
default=False,
description=(
"Whether to attach personally identifiable information (PII) such as "
"IP addresses and user agents to Sentry events. Disabled by default "
"for privacy compliance (GDPR / CCPA). Enable only if your Sentry "
"project is configured to handle PII."
),
)
@model_validator(mode="before")
@classmethod
def strip_outer_quotes(cls, data: Any) -> Any:
+4
View File
@@ -27,6 +27,7 @@ from app.middleware.request_size_limit import RequestSizeLimitMiddleware
from app.middleware.security_headers import SecurityHeadersMiddleware
from app.utils.config_validator import check_all_configs
from app.utils.notification import init_apprise, notify_shutdown, notify_startup
from app.utils.sentry import init_sentry
# Import the routers - now using views directly instead of frontend
from app.views import router as frontend_router
@@ -47,6 +48,9 @@ SESSION_SECRET = (
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
async def lifespan(app: FastAPI):
+113
View File
@@ -0,0 +1,113 @@
"""
Sentry integration utilities for DocuElevate.
Call ``init_sentry()`` early in your application entry point (before any
request handling) to enable error tracking and performance monitoring. The
function is a no-op when ``SENTRY_DSN`` is not configured, so it is safe to
call unconditionally in all environments.
Example (FastAPI)::
from app.utils.sentry import init_sentry
init_sentry()
Example (Celery worker)::
from app.utils.sentry import init_sentry
init_sentry(integrations_extra=["celery"])
"""
from __future__ import annotations
import logging
from app.config import settings
logger = logging.getLogger(__name__)
def init_sentry(*, integrations_extra: list[str] | None = None) -> bool:
"""
Initialise the Sentry SDK if ``SENTRY_DSN`` is configured.
Args:
integrations_extra: Optional list of additional integration names to
activate. Currently recognised values: ``"celery"``. The
``FastApiIntegration``, ``SqlalchemyIntegration``, and
``LoggingIntegration`` are always included when the SDK is
initialised.
Returns:
``True`` when Sentry was successfully initialised, ``False`` otherwise
(e.g. DSN not configured or SDK not installed).
"""
dsn = settings.sentry_dsn
if not dsn:
logger.debug("Sentry DSN not configured error monitoring disabled")
return False
try:
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
except ImportError:
logger.warning(
"sentry-sdk is not installed. Install it with: pip install 'sentry-sdk[fastapi,celery,sqlalchemy]'"
)
return False
integrations = [
StarletteIntegration(transaction_style="url"),
FastApiIntegration(transaction_style="url"),
SqlalchemyIntegration(),
LoggingIntegration(
level=logging.INFO, # Breadcrumbs from INFO+
event_level=logging.ERROR, # Send Sentry events for ERROR+
),
]
if integrations_extra and "celery" in integrations_extra:
try:
from sentry_sdk.integrations.celery import CeleryIntegration
integrations.append(CeleryIntegration(monitor_beat_tasks=True))
except ImportError:
logger.warning("CeleryIntegration not available skipping")
# Clamp sample rates to [0.0, 1.0]
traces_rate = max(0.0, min(1.0, settings.sentry_traces_sample_rate))
profiles_rate = max(0.0, min(1.0, settings.sentry_profiles_sample_rate))
version = _get_app_version()
sentry_sdk.init(
dsn=dsn,
environment=settings.sentry_environment,
release=version,
integrations=integrations,
traces_sample_rate=traces_rate,
profiles_sample_rate=profiles_rate,
send_default_pii=settings.sentry_send_default_pii,
# Attach a request body snapshot to every event (helps debugging)
max_request_body_size="medium",
# Keep the SDK from attaching local variable values to stack frames
# by default; enable explicitly if needed for deeper debugging.
attach_stacktrace=True,
)
logger.info(
"Sentry initialised (environment=%s, traces_sample_rate=%s)",
settings.sentry_environment,
traces_rate,
)
return True
def _get_app_version() -> str | None:
"""Return the application version string for Sentry release tracking."""
try:
return settings.version or None
except AttributeError: # pragma: no cover
return None
+58
View File
@@ -2264,6 +2264,64 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
# Observability Sentry
"sentry_dsn": {
"category": "Observability",
"description": (
"Sentry DSN (Data Source Name) URL. When set, runtime errors and "
"performance traces are automatically sent to Sentry. "
"Leave blank to disable Sentry entirely."
),
"type": "string",
"sensitive": True,
"required": False,
"restart_required": True,
},
"sentry_environment": {
"category": "Observability",
"description": (
"Environment label attached to every Sentry event "
"(e.g. 'development', 'staging', 'production'). "
"Helps you filter events in the Sentry dashboard. Default: 'production'."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"sentry_traces_sample_rate": {
"category": "Observability",
"description": (
"Fraction of transactions captured for Sentry performance monitoring (0.01.0). "
"0.0 disables tracing; 1.0 captures every request. Default: 0.1 (10%)."
),
"type": "float",
"sensitive": False,
"required": False,
"restart_required": True,
},
"sentry_profiles_sample_rate": {
"category": "Observability",
"description": (
"Fraction of profiled transactions sent to Sentry (0.01.0). "
"Only active when sentry_traces_sample_rate > 0. Default: 0.0 (disabled)."
),
"type": "float",
"sensitive": False,
"required": False,
"restart_required": True,
},
"sentry_send_default_pii": {
"category": "Observability",
"description": (
"Attach personally identifiable information (PII) such as IP addresses "
"to Sentry events. Disabled by default for GDPR/CCPA compliance."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
}
+25
View File
@@ -1178,6 +1178,31 @@ When a user is logged in, DocuElevate automatically passes their identity to the
No additional configuration is required — the auto-fill uses the authenticated session data (OAuth, local login, or admin credentials). Anonymous visitors see the standard Zammad widgets without pre-filled data.
## Observability Sentry
DocuElevate integrates with [Sentry](https://sentry.io) for real-time error tracking and performance monitoring. See [SentrySetup.md](./SentrySetup.md) for a full setup guide.
| Variable | Description | Default |
|---|---|---|
| `SENTRY_DSN` | Sentry DSN URL. When set, error reporting and performance tracing are enabled automatically. Leave blank to disable. | *(unset)* |
| `SENTRY_ENVIRONMENT` | Environment label attached to every Sentry event (`development`, `staging`, `production`, …). | `production` |
| `SENTRY_TRACES_SAMPLE_RATE` | Fraction of requests captured for performance tracing (0.0 1.0). `0.0` disables tracing entirely. | `0.1` |
| `SENTRY_PROFILES_SAMPLE_RATE` | Fraction of profiled transactions sent to Sentry (0.0 1.0). Only active when traces > 0. | `0.0` |
| `SENTRY_SEND_DEFAULT_PII` | Attach PII (IP addresses, user agents) to Sentry events. Disabled by default for GDPR/CCPA compliance. | `false` |
```bash
# Minimal example
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
SENTRY_ENVIRONMENT=production
# Optional tuning
SENTRY_TRACES_SAMPLE_RATE=0.1
SENTRY_PROFILES_SAMPLE_RATE=0.0
SENTRY_SEND_DEFAULT_PII=false
```
> **Note:** Sentry is completely opt-in — if `SENTRY_DSN` is not set, the SDK is never initialised and no data leaves your infrastructure.
## Duplicate Document Detection
DocuElevate detects and flags documents that share the same content, even if they arrive as separate uploads.
+189
View File
@@ -0,0 +1,189 @@
# Sentry Integration
DocuElevate ships with first-class support for [Sentry](https://sentry.io) — an open-source observability platform that provides real-time error tracking and performance monitoring.
When a **Sentry DSN** is configured, every unhandled exception in the FastAPI web process and Celery worker is automatically captured and sent to your Sentry project. Performance transactions (request traces, database queries, background task durations) are also recorded, giving you end-to-end visibility into your deployment.
---
## Quick Start
1. **Create a Sentry project** at <https://sentry.io> (or your self-hosted Sentry instance). Choose the **Python** platform.
2. Copy the **DSN** from *Project → Settings → Client Keys (DSN)*. It looks like:
```
https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
```
3. Set the DSN in your environment:
```bash
SENTRY_DSN=https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
```
4. Restart DocuElevate. Sentry initialises automatically on startup — you will see a log line confirming activation.
---
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `SENTRY_DSN` | *(empty)* | Sentry DSN URL. **Required** to enable Sentry. Leave unset to disable. |
| `SENTRY_ENVIRONMENT` | `production` | Environment label shown in the Sentry dashboard (`development`, `staging`, `production`, …). |
| `SENTRY_TRACES_SAMPLE_RATE` | `0.1` | Fraction of requests captured for performance tracing (0.0 1.0). `0.0` disables tracing entirely. |
| `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. |
All variables can alternatively be managed through the **Settings → Observability** section of the DocuElevate admin UI.
---
## Environment-Specific Configuration
### Development
```bash
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
SENTRY_ENVIRONMENT=development
SENTRY_TRACES_SAMPLE_RATE=1.0 # Capture every request during development
SENTRY_PROFILES_SAMPLE_RATE=1.0
SENTRY_SEND_DEFAULT_PII=true # OK in dev; disable in production
```
### Staging
```bash
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
SENTRY_ENVIRONMENT=staging
SENTRY_TRACES_SAMPLE_RATE=0.5
SENTRY_PROFILES_SAMPLE_RATE=0.0
SENTRY_SEND_DEFAULT_PII=false
```
### Production
```bash
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
SENTRY_ENVIRONMENT=production
SENTRY_TRACES_SAMPLE_RATE=0.1 # 10 % sampling keeps quota low
SENTRY_PROFILES_SAMPLE_RATE=0.0
SENTRY_SEND_DEFAULT_PII=false # Default — required for GDPR compliance
```
---
## Docker Compose
Add the variables to your `docker-compose.yaml` service definitions or to a separate `.env` file that Docker Compose reads automatically:
```yaml
services:
api:
environment:
SENTRY_DSN: "https://<key>@o<org>.ingest.sentry.io/<project>"
SENTRY_ENVIRONMENT: "production"
SENTRY_TRACES_SAMPLE_RATE: "0.1"
worker:
environment:
SENTRY_DSN: "https://<key>@o<org>.ingest.sentry.io/<project>"
SENTRY_ENVIRONMENT: "production"
SENTRY_TRACES_SAMPLE_RATE: "0.1"
```
---
## Kubernetes
Store the DSN as a Kubernetes Secret:
```bash
kubectl create secret generic docuelevate-sentry \
--from-literal=SENTRY_DSN="https://<key>@o<org>.ingest.sentry.io/<project>"
```
Then reference it in your Deployment manifest:
```yaml
envFrom:
- secretRef:
name: docuelevate-sentry
env:
- name: SENTRY_ENVIRONMENT
value: production
- name: SENTRY_TRACES_SAMPLE_RATE
value: "0.1"
```
---
## What Is Monitored
### FastAPI (web server)
- **Unhandled exceptions** — every 5xx error is automatically captured with the full stack trace and request context.
- **Performance transactions** — each HTTP request becomes a Sentry transaction, showing time spent in route handlers, middleware, and database calls.
- **Database queries** — individual SQL queries are recorded as child spans via the `SqlalchemyIntegration`.
### Celery (background worker)
- **Task failures** — any exception raised inside a Celery task is captured, including the task name, ID, and arguments.
- **Task performance** — each task execution is a Sentry transaction, letting you identify slow or failing pipelines.
- **Beat task monitoring** — the `CeleryIntegration(monitor_beat_tasks=True)` option tracks whether scheduled tasks run on time (requires [Sentry Crons](https://docs.sentry.io/product/crons/)).
### Logging integration
Log messages at `ERROR` level and above are automatically forwarded to Sentry as events. Messages at `INFO` level are recorded as breadcrumbs (contextual trail leading up to an error).
---
## Disabling Sentry
Simply leave `SENTRY_DSN` unset (or set it to an empty string). The SDK is never initialised and no data is sent.
---
## SDK Version
DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,<3.0.0` with the `fastapi`, `celery`, and `sqlalchemy` extras.
---
## Troubleshooting
### No events appear in Sentry
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`.
3. Test with `SENTRY_TRACES_SAMPLE_RATE=1.0` so that every request is sent.
### `sentry-sdk` import error
```
sentry-sdk is not installed.
```
Run `pip install 'sentry-sdk[fastapi,celery,sqlalchemy]'` inside your container, or rebuild the Docker image.
### PII / GDPR concerns
By default `SENTRY_SEND_DEFAULT_PII=false`, which prevents IP addresses and user agents from being attached to events. Review Sentry's [data management documentation](https://docs.sentry.io/product/data-management-settings/) and your organisation's privacy policy before enabling PII.
### High Sentry quota usage
Lower `SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.05` for 5 %) or set it to `0.0` to disable performance tracing entirely.
---
## Best Practices
- Use **separate Sentry projects** (or at least separate environments) for development, staging, and production so that noise from non-production environments does not pollute your production alerts.
- Configure **alert rules** in Sentry to notify your team (via email, Slack, PagerDuty, etc.) when error rates spike.
- Use **release tracking**: DocuElevate automatically sets `release` to the current `VERSION` string, enabling you to correlate errors with specific releases.
- Set up **performance baselines** using Sentry's Performance dashboard so that you can detect regressions after deployments.
- Review the [Sentry Python documentation](https://docs.sentry.io/platforms/python/) for advanced configuration options such as custom tags, user context, and scrubbing sensitive data.
---
## Related Documentation
- [Configuration Guide](./ConfigurationGuide.md) — full list of environment variables
- [Deployment Guide](./DeploymentGuide.md) — Docker and Kubernetes deployment
- [Troubleshooting](./Troubleshooting.md) — general troubleshooting
+4 -1
View File
@@ -47,4 +47,7 @@ pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
meilisearch>=0.31.0 # Full-text search engine client
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
# Error and performance monitoring
sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
+215
View File
@@ -0,0 +1,215 @@
"""
Tests for app/utils/sentry.py
Tests Sentry SDK initialisation logic, including:
- No-op when DSN is absent
- Successful init when DSN is present
- Celery integration activation
- Missing sentry-sdk package handling
- Sample rate clamping
"""
import builtins
from unittest.mock import MagicMock
import pytest
@pytest.mark.unit
class TestInitSentry:
"""Test init_sentry() behaviour under various configurations"""
def test_returns_false_when_no_dsn(self, mocker):
"""init_sentry returns False and does nothing when SENTRY_DSN is not set."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.sentry_dsn = None
from app.utils.sentry import init_sentry
result = init_sentry()
assert result is False
def test_returns_false_when_dsn_empty_string(self, mocker):
"""init_sentry returns False when SENTRY_DSN is an empty string."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.sentry_dsn = ""
from app.utils.sentry import init_sentry
result = init_sentry()
assert result is False
def test_returns_false_when_sentry_not_installed(self, mocker):
"""init_sentry returns False and logs a warning when sentry_sdk is missing."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.sentry_dsn = "https://key@sentry.io/123"
# Simulate sentry_sdk not being importable by patching the import at module level
original_import = builtins.__import__
def _block_sentry(name, *args, **kwargs):
if name.startswith("sentry_sdk"):
raise ImportError(f"No module named '{name}'")
return original_import(name, *args, **kwargs)
mocker.patch("builtins.__import__", side_effect=_block_sentry)
from app.utils.sentry import init_sentry
result = init_sentry()
assert result is False
def test_initialises_sentry_with_dsn(self, mocker):
"""init_sentry calls sentry_sdk.init when a DSN is provided."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.sentry_dsn = "https://key@o123.ingest.sentry.io/456"
mock_settings.sentry_environment = "production"
mock_settings.sentry_traces_sample_rate = 0.1
mock_settings.sentry_profiles_sample_rate = 0.0
mock_settings.sentry_send_default_pii = False
mock_settings.version = "1.2.3"
mock_sdk_init = mocker.patch("sentry_sdk.init")
# Patch the integrations so we don't need real Sentry internals
mocker.patch("sentry_sdk.integrations.fastapi.FastApiIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.starlette.StarletteIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.logging.LoggingIntegration", return_value=MagicMock())
from app.utils.sentry import init_sentry
result = init_sentry()
assert result is True
mock_sdk_init.assert_called_once()
call_kwargs = mock_sdk_init.call_args.kwargs
assert call_kwargs["dsn"] == "https://key@o123.ingest.sentry.io/456"
assert call_kwargs["environment"] == "production"
assert call_kwargs["release"] == "1.2.3"
assert call_kwargs["send_default_pii"] is False
def test_celery_integration_included_when_requested(self, mocker):
"""CeleryIntegration is appended when integrations_extra=['celery']."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.sentry_dsn = "https://key@o1.ingest.sentry.io/1"
mock_settings.sentry_environment = "staging"
mock_settings.sentry_traces_sample_rate = 0.5
mock_settings.sentry_profiles_sample_rate = 0.0
mock_settings.sentry_send_default_pii = False
mock_settings.version = None
mock_sdk_init = mocker.patch("sentry_sdk.init")
mock_celery_cls = mocker.patch("sentry_sdk.integrations.celery.CeleryIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.fastapi.FastApiIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.starlette.StarletteIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.logging.LoggingIntegration", return_value=MagicMock())
from app.utils.sentry import init_sentry
result = init_sentry(integrations_extra=["celery"])
assert result is True
mock_celery_cls.assert_called_once_with(monitor_beat_tasks=True)
def test_traces_sample_rate_clamped_above_one(self, mocker):
"""Traces sample rate values above 1.0 are clamped to 1.0."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.sentry_dsn = "https://key@o1.ingest.sentry.io/1"
mock_settings.sentry_environment = "test"
mock_settings.sentry_traces_sample_rate = 99.0 # invalid too high
mock_settings.sentry_profiles_sample_rate = 0.0
mock_settings.sentry_send_default_pii = False
mock_settings.version = None
mock_sdk_init = mocker.patch("sentry_sdk.init")
mocker.patch("sentry_sdk.integrations.fastapi.FastApiIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.starlette.StarletteIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.logging.LoggingIntegration", return_value=MagicMock())
from app.utils.sentry import init_sentry
init_sentry()
call_kwargs = mock_sdk_init.call_args.kwargs
assert call_kwargs["traces_sample_rate"] == 1.0
def test_traces_sample_rate_clamped_below_zero(self, mocker):
"""Traces sample rate values below 0.0 are clamped to 0.0."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.sentry_dsn = "https://key@o1.ingest.sentry.io/1"
mock_settings.sentry_environment = "test"
mock_settings.sentry_traces_sample_rate = -5.0 # invalid negative
mock_settings.sentry_profiles_sample_rate = 0.0
mock_settings.sentry_send_default_pii = False
mock_settings.version = None
mock_sdk_init = mocker.patch("sentry_sdk.init")
mocker.patch("sentry_sdk.integrations.fastapi.FastApiIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.starlette.StarletteIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.logging.LoggingIntegration", return_value=MagicMock())
from app.utils.sentry import init_sentry
init_sentry()
call_kwargs = mock_sdk_init.call_args.kwargs
assert call_kwargs["traces_sample_rate"] == 0.0
def test_no_celery_integration_when_not_requested(self, mocker):
"""CeleryIntegration is not included when integrations_extra is empty."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.sentry_dsn = "https://key@o1.ingest.sentry.io/1"
mock_settings.sentry_environment = "test"
mock_settings.sentry_traces_sample_rate = 0.0
mock_settings.sentry_profiles_sample_rate = 0.0
mock_settings.sentry_send_default_pii = False
mock_settings.version = None
mock_sdk_init = mocker.patch("sentry_sdk.init")
mock_celery_cls = mocker.patch("sentry_sdk.integrations.celery.CeleryIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.fastapi.FastApiIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.starlette.StarletteIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration", return_value=MagicMock())
mocker.patch("sentry_sdk.integrations.logging.LoggingIntegration", return_value=MagicMock())
from app.utils.sentry import init_sentry
init_sentry()
mock_celery_cls.assert_not_called()
mock_sdk_init.assert_called_once()
@pytest.mark.unit
class TestGetAppVersion:
"""Test the internal _get_app_version helper."""
def test_returns_version_from_settings(self, mocker):
"""_get_app_version returns settings.version when available."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.version = "2.0.0"
from app.utils.sentry import _get_app_version
assert _get_app_version() == "2.0.0"
def test_returns_none_when_version_unknown(self, mocker):
"""_get_app_version returns None when version is 'unknown' or empty."""
mock_settings = mocker.patch("app.utils.sentry.settings")
mock_settings.version = None
from app.utils.sentry import _get_app_version
assert _get_app_version() is None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------