Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions app/crud/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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


Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
37 changes: 37 additions & 0 deletions app/metrics.py
Original file line number Diff line number Diff line change
@@ -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"]
)
3 changes: 2 additions & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
55 changes: 55 additions & 0 deletions app/planner.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 12 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,7 +37,6 @@ services:
- backend
restart: unless-stopped


pgadmin:
image: dpage/pgadmin4
container_name: coli_pgadmin
Expand All @@ -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:
8 changes: 8 additions & 0 deletions prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
global:
scrape_interval: 15s

scrape_configs:
- job_name: 'coli_backend'
metrics_path: '/api/metrics'
static_configs:
- targets: ['backend:8000']
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ pydantic-settings
PyJWT
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
ruff
ruff
prometheus-client==0.20.0
apscheduler==3.10.4