From 572fc24a0a3059583e6a678822ce91aad25fc751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D1=86=D0=B5=D1=80=D0=BA=D0=BE=D0=B2=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B9=20=D0=94=D0=B0=D0=BD=D0=B8=D0=B8=D0=BB?= Date: Mon, 27 Apr 2026 16:47:00 +0500 Subject: [PATCH] add_prometheus --- app/crud/task.py | 30 ++++++++++++++++--------- app/main.py | 12 ++++++++++ app/metrics.py | 37 +++++++++++++++++++++++++++++++ app/models.py | 3 ++- app/planner.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 13 ++++++++++- prometheus.yml | 8 +++++++ requirements.txt | 4 +++- 8 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 app/metrics.py create mode 100644 app/planner.py create mode 100644 prometheus.yml diff --git a/app/crud/task.py b/app/crud/task.py index c1bf43d..8af045f 100644 --- a/app/crud/task.py +++ b/app/crud/task.py @@ -3,6 +3,7 @@ from app.models import Task, SpaceMember, Event from app.schemas import TaskCreate, TaskUpdate from sqlalchemy import or_ +from app.metrics import TASKS_CREATED, TASKS_COMPLETED, TASK_TIME_TO_ACTION, TASKS_OVERDUE def check_user_in_space(db: Session, user_id: int, space_id: int) -> bool: @@ -23,6 +24,8 @@ def create_task(db: Session, task: TaskCreate, space_id: int): db.add(db_task) db.commit() db.refresh(db_task) + + TASKS_CREATED.labels(space_id=str(space_id)).inc() return db_task @@ -37,13 +40,22 @@ def get_task_by_id(db: Session, task_id: int): def complete_task(db: Session, task: Task, user_id: int, username: str): + now = datetime.now(timezone.utc).replace(tzinfo=None) + is_overdue = False + if task.next_due_date and now > task.next_due_date: + is_overdue = True + event_payload = { "task_title": task.title, "user_name": username, "action": "completed", } + + start_time = task.created_at or (now - timedelta(days=int(task.frequency_days or 0))) + duration_seconds = (now - start_time).total_seconds() + TASK_TIME_TO_ACTION.labels(space_id=str(task.space_id)).observe(duration_seconds) + if task.is_recurring and task.frequency_days is not None: - now = datetime.now(timezone.utc).replace(tzinfo=None) if task.next_due_date: new_date = task.next_due_date + timedelta(days=int(task.frequency_days)) while new_date <= now: @@ -52,8 +64,15 @@ def complete_task(db: Session, task: Task, user_id: int, username: str): else: task.next_due_date = now + timedelta(days=int(task.frequency_days)) task.status = "active" + TASKS_CREATED.labels(space_id=str(task.space_id)).inc() + task.created_at = now else: task.status = "done" + task.completed_at = now + + TASKS_COMPLETED.labels(space_id=str(task.space_id)).inc() + if is_overdue: + TASKS_OVERDUE.labels(space_id=str(task.space_id)).inc() new_event = Event( space_id=task.space_id, @@ -69,15 +88,6 @@ def complete_task(db: Session, task: Task, user_id: int, username: str): return task -def update_task(db: Session, task: Task, update_data: TaskUpdate): - update_dict = update_data.model_dump(exclude_unset=True) - for key, value in update_dict.items(): - setattr(task, key, value) - db.commit() - db.refresh(task) - return task - - def delete_task(db: Session, task: Task): db.delete(task) db.commit() diff --git a/app/main.py b/app/main.py index 29124dc..d20ad26 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,20 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware + from app.routers import auth from app.routers import spaces from app.routers import tasks from app.routers import events from app.routers import users +from app.planner import start_scheduler + +from contextlib import asynccontextmanager +from prometheus_client import make_asgi_app +@asynccontextmanager +async def lifespan(app: FastAPI): + start_scheduler() + yield app = FastAPI(title="coli API", version="0.1.0", root_path="/api", docs_url="/docs") origins = ["*"] app.add_middleware( @@ -27,3 +36,6 @@ def health_check(): app.include_router(tasks.router, tags=["tasks"]) app.include_router(events.router, tags=["events"]) app.include_router(users.router, prefix="/users", tags=["users"]) + +metrics_app = make_asgi_app() +app.mount("/metrics", metrics_app) \ No newline at end of file diff --git a/app/metrics.py b/app/metrics.py new file mode 100644 index 0000000..8fead1b --- /dev/null +++ b/app/metrics.py @@ -0,0 +1,37 @@ +from prometheus_client import Counter, Histogram, Gauge + +TASKS_CREATED = Counter( + "colivin_tasks_created_total", + "Total created tasks (including recurring iterations)", + ["space_id"] +) + +TASKS_COMPLETED = Counter( + "colivin_tasks_completed_total", + "Total completed tasks", + ["space_id"] +) + +TASKS_OVERDUE = Counter( + "colivin_tasks_overdue_total", + "Total overdue tasks completed", + ["space_id"] +) + +TASK_TIME_TO_ACTION = Histogram( + "colivin_task_time_to_action_seconds", + "Time from task creation to completion", + ["space_id"], + buckets=(3600, 14400, 86400, 172800, 604800, float("inf")) +) + +ACTIVE_SPACES_PERCENT = Gauge( + "colivin_active_spaces_percent", + "Percentage of spaces with >3 tasks and >1 active user last week" +) + +USER_RETENTION_PERCENT = Gauge( + "colivin_user_retention_percent", + "User retention rate", + ["period"] +) \ No newline at end of file diff --git a/app/models.py b/app/models.py index a7b59eb..48c179e 100644 --- a/app/models.py +++ b/app/models.py @@ -51,8 +51,9 @@ class Task(Base): Integer, ForeignKey("users.id"), nullable=True ) status: Mapped[str] = mapped_column(String, default="active") - space: Mapped["Space"] = relationship("Space", back_populates="tasks") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) class SpaceMember(Base): diff --git a/app/planner.py b/app/planner.py new file mode 100644 index 0000000..87ec5d5 --- /dev/null +++ b/app/planner.py @@ -0,0 +1,55 @@ +from datetime import datetime, timedelta +from apscheduler.schedulers.background import BackgroundScheduler +from sqlalchemy import distinct +from app.database import SessionLocal +from app.models import Space, Task, Event, User +from app.metrics import ACTIVE_SPACES_PERCENT, USER_RETENTION_PERCENT + +def calculate_metrics(): + db = SessionLocal() + try: + now = datetime.now() + week_ago = now - timedelta(days=7) + + all_spaces = db.query(Space.id).all() + total_spaces_count = len(all_spaces) + if total_spaces_count > 0: + active_count = 0 + for (s_id,) in all_spaces: + created = db.query(Task).filter(Task.space_id == s_id, Task.created_at >= week_ago).count() + completed = db.query(Event).filter(Event.space_id == s_id, Event.event_type == "TASK_COMPLETED", Event.created_at >= week_ago).count() + active_people = db.query(distinct(Event.user_id)).filter(Event.space_id == s_id, Event.created_at >= week_ago).count() + + if created > 3 and completed > 3 and active_people > 1: + active_count += 1 + + ACTIVE_SPACES_PERCENT.set((active_count / total_spaces_count) * 100) + + def get_retention(days): + start_cohort = now - timedelta(days=days + 1) + end_cohort = now - timedelta(days=days) + cohort_users = db.query(User.id).filter(User.created_at >= start_cohort, User.created_at < end_cohort).all() + + if not cohort_users: + return 0.0 + + cohort_ids = [u[0] for u in cohort_users] + active_today = db.query(distinct(Event.user_id)).filter( + Event.user_id.in_(cohort_ids), + Event.created_at >= (now - timedelta(days=1)) + ).count() + + return (active_today / len(cohort_ids)) * 100 + + USER_RETENTION_PERCENT.labels(period="2_days").set(get_retention(2)) + USER_RETENTION_PERCENT.labels(period="7_days").set(get_retention(7)) + + except Exception as e: + print(f"[METRICS SCHEDULER ERROR]: {e}") + finally: + db.close() + +def start_scheduler(): + scheduler = BackgroundScheduler() + scheduler.add_job(calculate_metrics, 'interval', minutes=15, next_run_time=datetime.now()) + scheduler.start() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index aa993c7..a0a5ae9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} restart: unless-stopped + nginx: image: nginx:latest container_name: coli_nginx @@ -36,7 +37,6 @@ services: - backend restart: unless-stopped - pgadmin: image: dpage/pgadmin4 container_name: coli_pgadmin @@ -48,6 +48,17 @@ services: depends_on: - db restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: coli_prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + depends_on: + - backend + restart: unless-stopped volumes: postgres_data: \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..9f00f40 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'coli_backend' + metrics_path: '/api/metrics' + static_configs: + - targets: ['backend:8000'] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2707d44..09426d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,6 @@ pydantic-settings PyJWT passlib[bcrypt]==1.7.4 bcrypt==4.0.1 -ruff \ No newline at end of file +ruff +prometheus-client==0.20.0 +apscheduler==3.10.4 \ No newline at end of file