Skip to content
46 changes: 46 additions & 0 deletions backend/app/api/routes/cron.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from typing import Any

import sentry_sdk
from fastapi import APIRouter, Depends
Expand All @@ -9,6 +10,11 @@
from app.core.config import settings
from app.crud.evaluations import process_all_pending_evaluations
from app.services.job_monitoring import monitor_pending_jobs
from app.services.stats import (
collect_daily_stats,
format_daily_stats_message,
post_daily_stats_to_discord,
)

logger = logging.getLogger(__name__)

Expand All @@ -33,6 +39,16 @@
"recovery_threshold": 1,
}

DAILY_STATS_CRON_MONITOR_CONFIG: MonitorConfig = {
"schedule": {"type": "crontab", "value": "0 0 * * *"},
"timezone": "UTC",
"checkin_margin": 5,
"max_runtime": 10,
"failure_issue_threshold": 1,
"recovery_threshold": 1,
}


PENDING_JOBS_CRON_MONITOR_CONFIG: MonitorConfig = {
"schedule": {
"type": "interval",
Expand Down Expand Up @@ -123,6 +139,36 @@ async def evaluation_cron_job(
raise


@router.get(
"/cron/daily-stats",
include_in_schema=False,
dependencies=[Depends(require_permission(Permission.SUPERUSER))],
)
@sentry_sdk.monitor(
monitor_slug="daily-stats-cron-job",
monitor_config=DAILY_STATS_CRON_MONITOR_CONFIG,
)
def daily_stats_cron_job(
session: SessionDep, hours: int | None = None
) -> dict[str, Any]:
logger.info(f"[daily_stats_cron_job] Cron job invoked | hours={hours}")
try:
result = collect_daily_stats(session=session, window_hours=hours)
post_daily_stats_to_discord(format_daily_stats_message(result))
logger.info(
"[daily_stats_cron_job] Completed | window: %s",
result["window"],
)
return result
except Exception as e:
logger.error(
f"[daily_stats_cron_job] Error executing cron job: {e}",
exc_info=True,
)
sentry_sdk.capture_exception(e)
raise


@router.get(
"/cron/pending-jobs",
include_in_schema=False,
Expand Down
1 change: 1 addition & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class Settings(BaseSettings):
PROJECT_NAME: str
API_VERSION: str = "0.5.0"
SENTRY_DSN: HttpUrl | None = None
DISCORD_STATS_WEBHOOK_URL: HttpUrl | None = None
POSTGRES_SERVER: str
POSTGRES_PORT: int = 5432
POSTGRES_USER: str
Expand Down
120 changes: 120 additions & 0 deletions backend/app/crud/stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import logging
from datetime import datetime
from typing import Any

from sqlalchemy import TextClause, text
from sqlmodel import Session

logger = logging.getLogger(__name__)


_LLM_TOKEN_SUMMARY_SQL = text(
"""
SELECT
o.name AS organization_name,
l.model AS model,
COALESCE(SUM((l.usage->>'total_tokens')::INTEGER), 0) AS sum_total_tokens
FROM llm_call l
INNER JOIN organization o ON l.organization_id = o.id
WHERE l.inserted_at BETWEEN :start_at AND :end_at
AND l.deleted_at IS NULL
GROUP BY o.name, l.model
ORDER BY sum_total_tokens DESC
"""
)

_LLM_CALL_COUNTS_SQL = text(
"""
SELECT
o.name AS organization_name,
COUNT(*) AS call_count
FROM llm_call l
INNER JOIN organization o ON l.organization_id = o.id
WHERE l.inserted_at BETWEEN :start_at AND :end_at
AND l.deleted_at IS NULL
GROUP BY o.name
ORDER BY call_count DESC
"""
)

_LLM_MODALITY_COUNTS_SQL = text(
"""
SELECT
o.name AS organization_name,
CASE
WHEN l.input_type = 'text' AND l.output_type = 'text' THEN 'TEXT'
WHEN l.input_type = 'audio' AND l.output_type = 'text' THEN 'STT'
WHEN l.input_type = 'text' AND l.output_type = 'audio' THEN 'TTS'
ELSE 'OTHER'
END AS modality,
COUNT(*) AS call_count
FROM llm_call l
INNER JOIN organization o ON l.organization_id = o.id
WHERE l.inserted_at BETWEEN :start_at AND :end_at
AND l.deleted_at IS NULL
GROUP BY o.name, modality
ORDER BY o.name, modality
"""
)

_JOB_COUNTS_SQL = text(
"""
SELECT
o.name AS organization_name,
j.job_type AS job_type,
COUNT(*) AS job_count
FROM job j
INNER JOIN project p ON j.project_id = p.id
INNER JOIN organization o ON p.organization_id = o.id
WHERE j.inserted_at BETWEEN :start_at AND :end_at
GROUP BY o.name, j.job_type
ORDER BY o.name, j.job_type
"""
)


def _org_count_sql(table: str) -> TextClause:
return text(
f"""
SELECT
o.name AS organization_name,
COUNT(*) AS row_count
FROM {table} t
INNER JOIN organization o ON t.organization_id = o.id
WHERE t.inserted_at BETWEEN :start_at AND :end_at
GROUP BY o.name
ORDER BY row_count DESC
"""
)


_EVAL_RUN_COUNTS_SQL = _org_count_sql("evaluation_run")
_STT_RESULT_COUNTS_SQL = _org_count_sql("stt_result")
_TTS_RESULT_COUNTS_SQL = _org_count_sql("tts_result")
_ASSESSMENT_COUNTS_SQL = _org_count_sql("assessment")


def _rows(
session: Session, stmt: TextClause, params: dict[str, Any]
) -> list[dict[str, Any]]:
return [dict(row) for row in session.execute(stmt, params).mappings().all()]


def get_daily_stats(
*, session: Session, start_at: datetime, end_at: datetime
) -> dict[str, list[dict[str, Any]]]:
params = {"start_at": start_at, "end_at": end_at}
logger.info(
f"[get_daily_stats] Collecting stats | start_at: {start_at.isoformat()}, "
f"end_at: {end_at.isoformat()}"
)
return {
"llm_call_counts": _rows(session, _LLM_CALL_COUNTS_SQL, params),
"llm_call_token_summary": _rows(session, _LLM_TOKEN_SUMMARY_SQL, params),
"llm_call_modality_counts": _rows(session, _LLM_MODALITY_COUNTS_SQL, params),
"job_type_counts": _rows(session, _JOB_COUNTS_SQL, params),
"evaluation_run_counts": _rows(session, _EVAL_RUN_COUNTS_SQL, params),
"stt_result_counts": _rows(session, _STT_RESULT_COUNTS_SQL, params),
"tts_result_counts": _rows(session, _TTS_RESULT_COUNTS_SQL, params),
"assessment_counts": _rows(session, _ASSESSMENT_COUNTS_SQL, params),
}
139 changes: 139 additions & 0 deletions backend/app/services/stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import logging
from datetime import timedelta
from typing import Any

import requests
from sqlmodel import Session

from app.core.config import settings
from app.core.util import now
from app.crud.stats import get_daily_stats

logger = logging.getLogger(__name__)

DEFAULT_STATS_WINDOW = timedelta(hours=168) # 7-day rolling window by default
_MAX_ROWS_PER_SECTION = 20
_DISCORD_CHUNK_LIMIT = 1900 # Discord content cap is 2000; leave headroom


def collect_daily_stats(
*, session: Session, window_hours: int | None = None
) -> dict[str, Any]:
end_at = now()
start_at = end_at - (
timedelta(hours=window_hours) if window_hours else DEFAULT_STATS_WINDOW
)
Comment on lines +22 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

window_hours=0 silently falls back to the 7-day default.

if window_hours else DAILY_WINDOW treats 0 as falsy. Passing hours=0 would return a 7-day window instead of an empty one. Use an explicit None check.

🐛 Proposed fix
-    start_at = end_at - (
-        timedelta(hours=window_hours) if window_hours else DAILY_WINDOW
-    )
+    start_at = end_at - (
+        timedelta(hours=window_hours) if window_hours is not None else DAILY_WINDOW
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
end_at = now()
start_at = end_at - (
timedelta(hours=window_hours) if window_hours else DAILY_WINDOW
)
end_at = now()
start_at = end_at - (
timedelta(hours=window_hours) if window_hours is not None else DAILY_WINDOW
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/stats.py` around lines 22 - 25, In the window
calculation within the stats function, replace the truthiness check on
window_hours with an explicit None check so a value of 0 produces a
zero-duration window while only omitted values use DAILY_WINDOW.

stats = get_daily_stats(session=session, start_at=start_at, end_at=end_at)
return {
"window": {
"start_at": start_at.isoformat(),
"end_at": end_at.isoformat(),
},
"stats": stats,
}


_COL_ALIASES = {
"organization_name": "org",
"sum_total_tokens": "tokens",
"call_count": "calls",
"job_count": "jobs",
"row_count": "count",
}

_SECTION_TITLES = {
"llm_call_counts": "LLM Calls",
"llm_call_token_summary": "LLM Tokens",
"llm_call_modality_counts": "LLM Modality",
"job_type_counts": "Jobs by Type",
"evaluation_run_counts": "Evaluation Runs",
"stt_result_counts": "STT Results",
"tts_result_counts": "TTS Results",
"assessment_counts": "Assessments",
}


def _short_ts(iso: str) -> str:
return iso.split(".", 1)[0].replace("T", " ")


def _fmt_num(v: Any) -> str:
return f"{v:,}" if isinstance(v, int) else str(v)


def _render_section(section: str, rows: list[dict[str, Any]]) -> str | None:
title = _SECTION_TITLES.get(section, section)
if not rows:
return None
cols = list(rows[0].keys())
sample = rows[:_MAX_ROWS_PER_SECTION]
labels = {c: _COL_ALIASES.get(c, c) for c in cols}
is_num = {c: all(isinstance(r.get(c), (int, float)) for r in sample) for c in cols}
widths = {
c: max(len(labels[c]), *(len(_fmt_num(r.get(c, ""))) for r in sample))
for c in cols
}

def fmt(val: Any, c: str) -> str:
s = _fmt_num(val)
return f"{s:>{widths[c]}}" if is_num[c] else f"{s:<{widths[c]}}"

header = " ".join(fmt(labels[c], c) for c in cols)
body = "\n".join(" ".join(fmt(r.get(c, ""), c) for c in cols) for r in sample)
truncated = (
f"\n… +{len(rows) - _MAX_ROWS_PER_SECTION} more"
if len(rows) > _MAX_ROWS_PER_SECTION
else ""
)
return f"\n{title}\n```\n{header}\n{body}{truncated}\n```"


def format_daily_stats_message(result: dict[str, Any]) -> str:
window = result["window"]
start = _short_ts(window["start_at"])
end = _short_ts(window["end_at"])
blocks = [f"Daily Stats · {start} → {end} UTC"]

quiet: list[str] = []
for section, rows in result["stats"].items():
if not isinstance(rows, list):
continue
rendered = _render_section(section, rows)
if rendered is None:
quiet.append(_SECTION_TITLES.get(section, section))
else:
blocks.append(rendered)

if quiet:
blocks.append("\nNo data: " + ", ".join(quiet))
return "\n".join(blocks)


def _chunk_message(message: str, limit: int) -> list[str]:
chunks: list[str] = []
buf = ""
for block in message.split("\n\n"):
if len(buf) + len(block) + 2 > limit and buf:
chunks.append(buf)
buf = block
else:
buf = f"{buf}\n\n{block}" if buf else block
if buf:
chunks.append(buf)
return chunks
Comment thread
Ayush8923 marked this conversation as resolved.


def post_daily_stats_to_discord(message: str) -> None:
"""Fire-and-forget Discord post. No-op if webhook not configured.
Logs and stops on the first failed chunk — never raises."""
url = settings.DISCORD_STATS_WEBHOOK_URL
if not url:
return
for chunk in _chunk_message(message, _DISCORD_CHUNK_LIMIT):
try:
requests.post(
str(url), json={"content": chunk}, timeout=5
).raise_for_status()
except requests.RequestException as e:
logger.warning(f"[post_daily_stats_to_discord] Webhook post failed: {e}")
return
Loading
Loading