diff --git a/.env b/.env index 7d7e694..4116ee9 100644 --- a/.env +++ b/.env @@ -8,3 +8,8 @@ RQ_DASHBOARD_REDIS_URL=redis://redis:6379/0 POSTGRES_DB=aperisolve POSTGRES_USER=aperiuser POSTGRES_PASSWORD=aperipass +SENTRY_DSN=https://your-dsn@sentry.io/project-id +SENTRY_TRACES_SAMPLE_RATE=0.1 +SENTRY_PROFILES_SAMPLE_RATE=0.1 +SENTRY_RELEASE=1.0.0 +ENVIRONMENT=production \ No newline at end of file diff --git a/aperisolve/app.py b/aperisolve/app.py index f9e2ff9..1b0a342 100644 --- a/aperisolve/app.py +++ b/aperisolve/app.py @@ -9,6 +9,12 @@ from pathlib import Path from shutil import rmtree from typing import Any, Optional +import sentry_sdk +from sentry_sdk.integrations.flask import FlaskIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration +from sentry_sdk.integrations.rq import RqIntegration +from sentry_sdk.integrations.threading import ThreadingIntegration + from flask import Flask, Response, abort, jsonify, render_template, request, send_file from redis import Redis from rq import Queue @@ -17,6 +23,33 @@ from .cleanup import cleanup_old_entries from .config import IMAGE_EXTENSIONS, RESULT_FOLDER, WORKER_FILES from .models import Image, Submission, db, init_db +SENTRY_DSN = os.environ.get("SENTRY_DSN") +ENVIRONMENT = os.environ.get("ENVIRONMENT", "development") + +if SENTRY_DSN: + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[ + FlaskIntegration(), + SqlalchemyIntegration(), + RqIntegration(), + ThreadingIntegration(propagate_hub=True), + ], + # Set traces_sample_rate to 1.0 to capture 100% of transactions for performance monitoring. + # Adjust this value in production (e.g., 0.1 for 10%) + traces_sample_rate=float(os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "0.1")), + # Set profiles_sample_rate to 1.0 to profile 100% of sampled transactions. + profiles_sample_rate=float(os.environ.get("SENTRY_PROFILES_SAMPLE_RATE", "0.1")), + # Environment tag (production, staging, development) + environment=ENVIRONMENT, + # Release version (use git commit hash or version number) + release=os.environ.get("SENTRY_RELEASE", "1.0.0"), + # Send default PII (user IPs, etc.) - set to False for privacy + send_default_pii=False, + # Enable automatic breadcrumb tracking + enable_tracing=True, + ) + app: Flask = Flask(__name__) app.json.sort_keys = False # type: ignore app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DB_URI") @@ -36,6 +69,11 @@ queue = Queue(connection=redis_conn) @app.errorhandler(413) def too_large(_: Any) -> tuple[Response, int]: """Error Handler for Max File Size.""" + sentry_sdk.set_context("upload", { + "content_length": request.content_length, + "max_allowed": app.config["MAX_CONTENT_LENGTH"], + }) + sentry_sdk.capture_message("Upload failed: 413 Payload Too Large", level="warning") return jsonify({"error": "Image size exceeded"}), 413 @@ -44,6 +82,7 @@ def not_found(_: Any) -> str: """Error Handler for 404 not found.""" return render_template("error.html", message="Resource not found", statuscode=404) +# Sentry automatically captures 500 errors @app.route("/") def index() -> str: diff --git a/aperisolve/requirements.txt b/aperisolve/requirements.txt index 14446ca..f7ee61b 100644 --- a/aperisolve/requirements.txt +++ b/aperisolve/requirements.txt @@ -7,4 +7,5 @@ psycopg2-binary types-Flask-SQLAlchemy sqlalchemy-stubs Pillow -gunicorn \ No newline at end of file +gunicorn +sentry-sdk[flask,sqlalchemy,rq] \ No newline at end of file diff --git a/aperisolve/workers.py b/aperisolve/workers.py index c5be8c7..d577fa2 100644 --- a/aperisolve/workers.py +++ b/aperisolve/workers.py @@ -1,9 +1,31 @@ """Asynchronous worker for analyzing image submissions.""" +import os import threading from pathlib import Path from typing import Any +import sentry_sdk +from sentry_sdk.integrations.rq import RqIntegration +from sentry_sdk.integrations.threading import ThreadingIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration + +# Initialize Sentry for workers (separate process from Flask app) +SENTRY_DSN = os.environ.get("SENTRY_DSN") +if SENTRY_DSN: + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[ + RqIntegration(), + ThreadingIntegration(propagate_hub=True), # Capture exceptions in threads + SqlalchemyIntegration(), + ], + traces_sample_rate=float(os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "0.1")), + environment=os.environ.get("ENVIRONMENT", "development"), + release=os.environ.get("SENTRY_RELEASE", "1.0.0"), + send_default_pii=False, + ) + from .analyzers.binwalk import analyze_binwalk from .analyzers.decomposer import analyze_decomposer from .analyzers.exiftool import analyze_exiftool @@ -51,6 +73,10 @@ def analyze_image(submission_hash: str) -> None: image = Image.query.get_or_404(submission.image_hash) # type: ignore if not submission or not image: # No submission found + sentry_sdk.capture_message( + f"Submission or image not found: {submission_hash}", + level="warning" + ) return submission.status = "running" # type: ignore @@ -69,6 +95,7 @@ def analyze_image(submission_hash: str) -> None: analyzer_func(*args) except Exception as e: print(f"Error in {analyzer_func.__name__}: {e}") + sentry_sdk.capture_exception(e) analyzers = [ (analyze_binwalk, img_path, result_path), @@ -100,7 +127,10 @@ def analyze_image(submission_hash: str) -> None: thread.join() submission.status = "completed" - except Exception: + except Exception as e: + sentry_sdk.capture_exception(e) submission.status = "error" finally: db.session.commit() # pylint: disable=no-member + # Flush Sentry events - wait up to 5 seconds for events to be sent + sentry_sdk.flush(timeout=5)