diff --git a/.env.example b/.env.example index 262d3ba..fffb471 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,8 @@ DAILY_CALL_LIMIT=50 # Production frontend origin for CORS (e.g. https://your-app.railway.app) # ALLOWED_ORIGIN=https://your-app.railway.app + +# Sentry DSN for production error monitoring — optional, get one free at +# https://sentry.io. Leave unset to disable error monitoring entirely (safe +# default for local dev/CI). +# SENTRY_DSN=https://...@o0.ingest.sentry.io/0 diff --git a/agent/main.py b/agent/main.py index 8415923..423a0b8 100644 --- a/agent/main.py +++ b/agent/main.py @@ -5,6 +5,7 @@ from datetime import date import anthropic +import sentry_sdk from dotenv import load_dotenv from agent import db @@ -249,6 +250,7 @@ def _run_tools(response_content: list, user_id: int, on_result=None, source: str result = TOOL_HANDLERS[block.name](**kwargs) except Exception: logger.error("tool %s(%s) failed:\n%s", block.name, kwargs, traceback.format_exc()) + sentry_sdk.capture_exception() result = {"status": "error", "message": f"{block.name} failed unexpectedly — tell the user and don't retry automatically."} db.record_usage(user_id, "tool", block.name, source) print(f"[tool] {block.name}({kwargs}) -> {result}") diff --git a/api/server.py b/api/server.py index 23188aa..0b69620 100644 --- a/api/server.py +++ b/api/server.py @@ -7,6 +7,7 @@ from datetime import date from pathlib import Path +import sentry_sdk from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse @@ -37,6 +38,16 @@ logger = logging.getLogger(__name__) +# Error monitoring — a no-op until SENTRY_DSN is set (sentry_sdk.init(dsn=None) +# disables the SDK entirely, so this is safe to leave unconfigured in dev/CI). +# Error capture only, no performance tracing — that's a separate cost/scope +# this app doesn't need yet. send_default_pii is explicitly off: the SDK +# auto-instruments the anthropic client, and every Claude call here carries +# real expense/income descriptions — that must never leave this app for a +# third party, on top of the SYSTEM prompt/user messages being sensitive on +# their own. +sentry_sdk.init(dsn=os.environ.get("SENTRY_DSN"), traces_sample_rate=0.0, send_default_pii=False) + DAILY_CALL_LIMIT = int(os.environ.get("DAILY_CALL_LIMIT", 50)) @@ -110,6 +121,7 @@ def generate(): yield f"data: {json.dumps(event)}\n\n" except Exception: logger.error("stream_chat error:\n%s", traceback.format_exc()) + sentry_sdk.capture_exception() yield f"data: {json.dumps({'error': 'Something went wrong. Please try again.'})}\n\n" yield "data: [DONE]\n\n" diff --git a/pyproject.toml b/pyproject.toml index a4b00d1..6a8f3bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "psycopg2-binary>=2.9.12", "python-dotenv>=1.2.2", "python-jose[cryptography]>=3.5.0", + "sentry-sdk>=2.65.0", "uvicorn>=0.49.0", ] diff --git a/tests/test_agent.py b/tests/test_agent.py index f8efaf4..7e60f59 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -125,6 +125,21 @@ def failing_handler(**kw): assert results[2]["content"] == str({"ok": True}) +def test_run_tools_reports_failing_handler_to_sentry(monkeypatch): + captured = [] + monkeypatch.setattr(main.sentry_sdk, "capture_exception", lambda: captured.append(True)) + + def failing_handler(**kw): + raise ValueError("boom") + + monkeypatch.setitem(main.TOOL_HANDLERS, "save_expense", failing_handler) + block = make_block("tool_use", name="save_expense", input={"amount": 5}, id="tool_1") + + main._run_tools([block], user_id=1) + + assert captured == [True] + + # --- _serialize_block -------------------------------------------------------- # Required for persisting assistant turns to Postgres — a live Anthropic SDK # response block in production, but test doubles are plain dicts or diff --git a/tests/test_api.py b/tests/test_api.py index d273695..e64991c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -302,6 +302,21 @@ def fake_stream_chat(message, user_id, username, images, source): assert "data: [DONE]" in response.text +def test_chat_stream_endpoint_reports_exception_to_sentry(monkeypatch, auth_headers): + captured = [] + monkeypatch.setattr(server.sentry_sdk, "capture_exception", lambda: captured.append(True)) + + def fake_stream_chat(message, user_id, username, images): + raise RuntimeError("boom") + yield # pragma: no cover — makes this a generator, never reached + + monkeypatch.setattr(server, "stream_chat", fake_stream_chat) + + client.post("/chat/stream", json={"message": "hi"}, headers=auth_headers) + + assert captured == [True] + + # --- check_rate_limit -------------------------------------------------- def test_check_rate_limit_allows_calls_under_limit(user_id): diff --git a/uv.lock b/uv.lock index a379ccc..8bba098 100644 --- a/uv.lock +++ b/uv.lock @@ -295,6 +295,7 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "python-dotenv" }, { name = "python-jose", extra = ["cryptography"] }, + { name = "sentry-sdk" }, { name = "uvicorn" }, ] @@ -313,6 +314,7 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, + { name = "sentry-sdk", specifier = ">=2.65.0" }, { name = "uvicorn", specifier = ">=0.49.0" }, ] @@ -644,6 +646,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, ] +[[package]] +name = "sentry-sdk" +version = "2.65.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/1f/ed17a390348156ca99fe622b97cd7d2f1969b5f49df89084b0f28e7953e9/sentry_sdk-2.65.0.tar.gz", hash = "sha256:c94dc945d54bad49d4f20448b1e6b217ca2f92f46d05c3e83d41764af685c3d1", size = 932133, upload-time = "2026-07-13T11:33:19.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/3b/326ad4c03b5da89b5124c8890af66e8119c4d2e10abc0619e0d67d9f7c7f/sentry_sdk-2.65.0-py3-none-any.whl", hash = "sha256:3595169677a808e4d0e1ea6ffb89443459549c7a98392ed71c77c847182ab6bf", size = 503869, upload-time = "2026-07-13T11:33:17.71Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -695,6 +710,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "uvicorn" version = "0.49.0"