Add Sentry integration for error tracking; update requirements and configuration files. Introduce new Alembic migration for missing indexes and constraints in the database schema.

This commit is contained in:
mohamad 2025-05-13 21:45:45 +02:00
parent 9583aa4bab
commit 18f759aa7c
8 changed files with 300 additions and 21 deletions

View File

@ -0,0 +1,60 @@
"""add_missing_indexes_and_constraints
Revision ID: 7c26d62e8005
Revises: bc37e9c7ae19
Create Date: 2025-05-13 21:44:46.408395
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7c26d62e8005'
down_revision: Union[str, None] = 'bc37e9c7ae19'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_index('ix_expense_splits_user_id', 'expense_splits', ['user_id'], unique=False)
op.create_index(op.f('ix_expenses_group_id'), 'expenses', ['group_id'], unique=False)
op.create_index(op.f('ix_expenses_list_id'), 'expenses', ['list_id'], unique=False)
op.create_index(op.f('ix_expenses_paid_by_user_id'), 'expenses', ['paid_by_user_id'], unique=False)
op.create_index(op.f('ix_settlements_group_id'), 'settlements', ['group_id'], unique=False)
op.create_index(op.f('ix_settlements_paid_by_user_id'), 'settlements', ['paid_by_user_id'], unique=False)
op.create_index(op.f('ix_settlements_paid_to_user_id'), 'settlements', ['paid_to_user_id'], unique=False)
# Add check constraints
op.create_check_constraint(
'chk_expense_context',
'expenses',
'(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)'
)
op.create_check_constraint(
'chk_settlement_different_users',
'settlements',
'paid_by_user_id != paid_to_user_id'
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Drop check constraints
op.drop_constraint('chk_settlement_different_users', 'settlements', type_='check')
op.drop_constraint('chk_expense_context', 'expenses', type_='check')
op.drop_index(op.f('ix_settlements_paid_to_user_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_paid_by_user_id'), table_name='settlements')
op.drop_index(op.f('ix_settlements_group_id'), table_name='settlements')
op.drop_index(op.f('ix_expenses_paid_by_user_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_list_id'), table_name='expenses')
op.drop_index(op.f('ix_expenses_group_id'), table_name='expenses')
op.drop_index('ix_expense_splits_user_id', table_name='expense_splits')
# ### end Alembic commands ###

View File

@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str | None = None DATABASE_URL: str | None = None
GEMINI_API_KEY: str | None = None GEMINI_API_KEY: str | None = None
SENTRY_DSN: str | None = None # Sentry DSN for error tracking
# --- JWT Settings --- # --- JWT Settings ---
SECRET_KEY: str # Must be set via environment variable SECRET_KEY: str # Must be set via environment variable

View File

@ -3,6 +3,8 @@ import logging
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from app.api.api_router import api_router from app.api.api_router import api_router
from app.config import settings from app.config import settings
@ -10,6 +12,20 @@ from app.core.api_config import API_METADATA, API_TAGS
# Import database and models if needed for startup/shutdown events later # Import database and models if needed for startup/shutdown events later
# from . import database, models # from . import database, models
# Initialize Sentry
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
integrations=[
FastApiIntegration(),
],
# Set traces_sample_rate to 1.0 to capture 100% of transactions for performance monitoring.
# We recommend adjusting this value in production.
traces_sample_rate=1.0,
# If you wish to associate users to errors (assuming you are using
# FastAPI's users system) you may enable sending PII data.
send_default_pii=True
)
# --- Logging Setup --- # --- Logging Setup ---
logging.basicConfig( logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL), level=getattr(logging, settings.LOG_LEVEL),

View File

@ -19,7 +19,8 @@ from sqlalchemy import (
func, func,
text as sa_text, text as sa_text,
Text, # <-- Add Text for description Text, # <-- Add Text for description
Numeric # <-- Add Numeric for price Numeric, # <-- Add Numeric for price
CheckConstraint
) )
from sqlalchemy.orm import relationship, backref from sqlalchemy.orm import relationship, backref
@ -184,15 +185,15 @@ class Expense(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
description = Column(String, nullable=False) description = Column(String, nullable=False)
total_amount = Column(Numeric(10, 2), nullable=False) total_amount = Column(Numeric(10, 2), nullable=False)
currency = Column(String, nullable=False, default="USD") # Consider making this an Enum too if few currencies currency = Column(String, nullable=False, default="USD")
expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False) split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False)
# Foreign Keys # Foreign Keys
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True) list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # If not list-specific but group-specific group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True)
item_id = Column(Integer, ForeignKey("items.id"), nullable=True) # If the expense is for a specific item item_id = Column(Integer, ForeignKey("items.id"), nullable=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
@ -206,27 +207,25 @@ class Expense(Base):
splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan") splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan")
__table_args__ = ( __table_args__ = (
# Example: Ensure either list_id or group_id is present if item_id is null # Ensure at least one context is provided
# CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'), CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
) )
class ExpenseSplit(Base): class ExpenseSplit(Base):
__tablename__ = "expense_splits" __tablename__ = "expense_splits"
__table_args__ = (UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split'),) __table_args__ = (
UniqueConstraint('expense_id', 'user_id', name='uq_expense_user_split'),
Index('ix_expense_splits_user_id', 'user_id'), # For looking up user's splits
)
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
expense_id = Column(Integer, ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False) expense_id = Column(Integer, ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owed_amount = Column(Numeric(10, 2), nullable=False) # For EQUAL or EXACT_AMOUNTS owed_amount = Column(Numeric(10, 2), nullable=False)
# For PERCENTAGE split (value from 0.00 to 100.00)
share_percentage = Column(Numeric(5, 2), nullable=True) share_percentage = Column(Numeric(5, 2), nullable=True)
# For SHARES split (e.g., user A has 2 shares, user B has 3 shares)
share_units = Column(Integer, nullable=True) share_units = Column(Integer, nullable=True)
# is_settled might be better tracked via actual Settlement records or a reconciliation process
# is_settled = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
@ -234,14 +233,13 @@ class ExpenseSplit(Base):
expense = relationship("Expense", back_populates="splits") expense = relationship("Expense", back_populates="splits")
user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits") user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits")
class Settlement(Base): class Settlement(Base):
__tablename__ = "settlements" __tablename__ = "settlements"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=False) # Settlements usually within a group group_id = Column(Integer, ForeignKey("groups.id"), nullable=False, index=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) paid_to_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
amount = Column(Numeric(10, 2), nullable=False) amount = Column(Numeric(10, 2), nullable=False)
settlement_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) settlement_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
@ -257,7 +255,7 @@ class Settlement(Base):
__table_args__ = ( __table_args__ = (
# Ensure payer and payee are different users # Ensure payer and payee are different users
# CheckConstraint('paid_by_user_id <> paid_to_user_id', name='chk_settlement_payer_ne_payee'), CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'),
) )
# Potential future: PaymentMethod model, etc. # Potential future: PaymentMethod model, etc.

View File

@ -9,4 +9,5 @@ python-dotenv>=1.0.0 # To load .env file for scripts/alembic
passlib[bcrypt]>=1.7.4 passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
pydantic[email] pydantic[email]
google-generativeai>=0.5.0 google-generativeai>=0.5.0
sentry-sdk[fastapi]>=1.39.0

182
fe/package-lock.json generated
View File

@ -8,6 +8,8 @@
"name": "fe", "name": "fe",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3",
"@supabase/auth-js": "^2.69.1", "@supabase/auth-js": "^2.69.1",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",
@ -3825,6 +3827,162 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sentry-internal/feedback": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.120.3.tgz",
"integrity": "sha512-ewJJIQ0mbsOX6jfiVFvqMjokxNtgP3dNwUv+4nenN+iJJPQsM6a0ocro3iscxwVdbkjw5hY3BUV2ICI5Q0UWoA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.120.3.tgz",
"integrity": "sha512-s5xy+bVL1eDZchM6gmaOiXvTqpAsUfO7122DxVdEDMtwVq3e22bS2aiGa8CUgOiJkulZ+09q73nufM77kOmT/A==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/replay": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry-internal/tracing": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.3.tgz",
"integrity": "sha512-Ausx+Jw1pAMbIBHStoQ6ZqDZR60PsCByvHdw/jdH9AqPrNE9xlBSf9EwcycvmrzwyKspSLaB52grlje2cRIUMg==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/browser": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.120.3.tgz",
"integrity": "sha512-i9vGcK9N8zZ/JQo1TCEfHHYZ2miidOvgOABRUc9zQKhYdcYQB2/LU1kqlj77Pxdxf4wOa9137d6rPrSn9iiBxg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/feedback": "7.120.3",
"@sentry-internal/replay-canvas": "7.120.3",
"@sentry-internal/tracing": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/integrations": "7.120.3",
"@sentry/replay": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/core": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.120.3.tgz",
"integrity": "sha512-vyy11fCGpkGK3qI5DSXOjgIboBZTriw0YDx/0KyX5CjIjDDNgp5AGgpgFkfZyiYiaU2Ww3iFuKo4wHmBusz1uA==",
"license": "MIT",
"dependencies": {
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/integrations": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.120.3.tgz",
"integrity": "sha512-6i/lYp0BubHPDTg91/uxHvNui427df9r17SsIEXa2eKDwQ9gW2qRx5IWgvnxs2GV/GfSbwcx4swUB3RfEWrXrQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3",
"localforage": "^1.8.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/replay": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.120.3.tgz",
"integrity": "sha512-CjVq1fP6bpDiX8VQxudD5MPWwatfXk8EJ2jQhJTcWu/4bCSOQmHxnnmBM+GVn5acKUBCodWHBN+IUZgnJheZSg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/tracing": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@sentry/tracing": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.120.3.tgz",
"integrity": "sha512-B7bqyYFgHuab1Pn7w5KXsZP/nfFo4VDBDdSXDSWYk5+TYJ3IDruO3eJFhOrircfsz4YwazWm9kbeZhkpsHDyHg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/tracing": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/types": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.120.3.tgz",
"integrity": "sha512-C4z+3kGWNFJ303FC+FxAd4KkHvxpNFYAFN8iMIgBwJdpIl25KZ8Q/VdGn0MLLUEHNLvjob0+wvwlcRBBNLXOow==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/utils": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.120.3.tgz",
"integrity": "sha512-UDAOQJtJDxZHQ5Nm1olycBIsz2wdGX8SdzyGVHmD8EOQYAeDZQyIlQYohDe9nazdIOQLZCIc3fU0G9gqVLkaGQ==",
"license": "MIT",
"dependencies": {
"@sentry/types": "7.120.3"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@sentry/vue": {
"version": "7.120.3",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-7.120.3.tgz",
"integrity": "sha512-YKKLGx6VNk5OTz5JqIsjIqOgaU8u88Q1OBfLZgOpm55vhrvpZGGc+rHyh8XtXxh4DfC+6vTRTrAngvdPOG9Oxw==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "7.120.3",
"@sentry/core": "7.120.3",
"@sentry/types": "7.120.3",
"@sentry/utils": "7.120.3"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"vue": "2.x || 3.x"
}
},
"node_modules/@sindresorhus/merge-streams": { "node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@ -7440,6 +7598,12 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
@ -8413,6 +8577,24 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"license": "Apache-2.0",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",

View File

@ -17,6 +17,8 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@sentry/tracing": "^7.120.3",
"@sentry/vue": "^7.120.3",
"@supabase/auth-js": "^2.69.1", "@supabase/auth-js": "^2.69.1",
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",

View File

@ -1,5 +1,7 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import * as Sentry from '@sentry/vue';
import { BrowserTracing } from '@sentry/tracing';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
// import { createI18n } from 'vue-i18n'; // import { createI18n } from 'vue-i18n';
@ -33,6 +35,23 @@ const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); app.use(pinia);
// Initialize Sentry
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracingOrigins: ['localhost', /^\//],
}),
],
// Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: 1.0,
// Set environment
environment: import.meta.env.MODE,
});
// Initialize auth state before mounting the app // Initialize auth state before mounting the app
const authStore = useAuthStore(); const authStore = useAuthStore();
if (authStore.accessToken) { if (authStore.accessToken) {