From 58af9e5a29e01332c8d6a5886bf09b87d8fa2e9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:09:17 +0000 Subject: [PATCH] feat(observability): add Sentry error and performance monitoring integration Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .env.demo | 20 ++++ app/celery_app.py | 10 +- app/config.py | 44 +++++++ app/main.py | 4 + app/utils/sentry.py | 113 ++++++++++++++++++ app/utils/settings_service.py | 58 +++++++++ docs/ConfigurationGuide.md | 25 ++++ docs/SentrySetup.md | 189 ++++++++++++++++++++++++++++++ requirements.txt | 5 +- tests/test_sentry.py | 215 ++++++++++++++++++++++++++++++++++ 10 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 app/utils/sentry.py create mode 100644 docs/SentrySetup.md create mode 100644 tests/test_sentry.py diff --git a/.env.demo b/.env.demo index f33432a0..65f82fdf 100644 --- a/.env.demo +++ b/.env.demo @@ -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://@o.ingest.sentry.io/ +# +# Environment label shown in the Sentry dashboard (e.g. development / staging / production). +# SENTRY_ENVIRONMENT=production +# +# Fraction of requests to capture for performance tracing (0.0–1.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.0–1.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 diff --git a/app/celery_app.py b/app/celery_app.py index 97572f1e..db10b6a4 100644 --- a/app/celery_app.py +++ b/app/celery_app.py @@ -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 diff --git a/app/config.py b/app/config.py index 68297ed9..ba6eeb25 100644 --- a/app/config.py +++ b/app/config.py @@ -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.0–1.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.0–1.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: diff --git a/app/main.py b/app/main.py index 71507011..f6f3c890 100644 --- a/app/main.py +++ b/app/main.py @@ -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): diff --git a/app/utils/sentry.py b/app/utils/sentry.py new file mode 100644 index 00000000..8f0ad333 --- /dev/null +++ b/app/utils/sentry.py @@ -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 diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 9233712e..7516f8f9 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -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.0–1.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.0–1.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, + }, } diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index fcb22f84..6df53ac3 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -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://@o.ingest.sentry.io/ +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. diff --git a/docs/SentrySetup.md b/docs/SentrySetup.md new file mode 100644 index 00000000..8bf5091d --- /dev/null +++ b/docs/SentrySetup.md @@ -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 (or your self-hosted Sentry instance). Choose the **Python** platform. +2. Copy the **DSN** from *Project → Settings → Client Keys (DSN)*. It looks like: + ``` + https://@o.ingest.sentry.io/ + ``` +3. Set the DSN in your environment: + ```bash + SENTRY_DSN=https://@o.ingest.sentry.io/ + ``` +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://@o.ingest.sentry.io/ +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://@o.ingest.sentry.io/ +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://@o.ingest.sentry.io/ +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://@o.ingest.sentry.io/" + SENTRY_ENVIRONMENT: "production" + SENTRY_TRACES_SAMPLE_RATE: "0.1" + + worker: + environment: + SENTRY_DSN: "https://@o.ingest.sentry.io/" + 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://@o.ingest.sentry.io/" +``` + +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 diff --git a/requirements.txt b/requirements.txt index b8016a6e..6593a7a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_sentry.py b/tests/test_sentry.py new file mode 100644 index 00000000..ca0b12bb --- /dev/null +++ b/tests/test_sentry.py @@ -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 +# ---------------------------------------------------------------------------