From 311c7341a11aa1c228361de5fc2ff6242ea1bb0a Mon Sep 17 00:00:00 2001 From: Mish Date: Tue, 3 Feb 2026 00:53:50 +0000 Subject: [PATCH] Add a stats v2 endpoint (#154) * Add a stats v2 endpoint * Make helpers_leaderboard json-friendly * Improve naming of hang time stats * Make new_tickets_now_closed do what it says for ranges in the past * Add some docs! * Split all-time hang time into unresolved/all * Add a statistic for oldest question waiting for a helper response (#152) * Silence type error (that was annoying me) * Add new metric: oldest_unanswered_ticket_age_minutes * Fix datetime subtraction exception * Add oldest unanswered ticket info to stats v2 --- docs/api.md | 76 ++++++++++++++ nephthys/api/stats.py | 6 +- nephthys/api/stats_v2.py | 40 ++++++++ nephthys/utils/starlette.py | 2 + nephthys/utils/stats.py | 99 +++++++++++++++---- .../views/home/components/leaderboards.py | 8 +- 6 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 docs/api.md create mode 100644 nephthys/api/stats_v2.py diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..b637b60 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,76 @@ +# Nephthys API docs + +There is no proper API documentation (apart from [the code](https://github.com/hackclub/nephthys/blob/main/nephthys/utils/starlette.py)) yet, but +here's some explanation of the important routes. + +All routes respond with JSON. + +## `/api/stats_v2` + +Statistics! Gives a bunch of metric-like statistic numbers so that you +can see how the support channel is doing. + +Gives a response like: + +```ts +interface StatsV2 { + all_time: OverallStats + past_24h: TimeBoundStats + past_24h_previous: TimeBoundStats + past_7d: TimeBoundStats + past_7d_previous: TimeBoundStats +} + +interface OverallStats { + tickets_total: number + tickets_open: number + tickets_closed: number + tickets_in_progress: number + helpers_leaderboard: Array + mean_hang_time_minutes_unresolved: number | null + mean_hang_time_minutes_all: number | null + mean_resolution_time_minutes: number | null + oldest_unanswered_ticket: OldestUnansweredTicket | null +} + +interface TimeBoundStats { + /** Tickets created within the time period */ + new_tickets_total: number + /** Tickets created within the time period and now closed */ + new_tickets_now_closed: number + /** Tickets created within time period that are now open */ + new_tickets_still_open: number + /** Tickets created within time period that are now in progress */ + new_tickets_in_progress: number + /** Tickets closed within the time period */ + closed_today: number + /** Tickets created and closed within the time period */ + closed_today_from_today: number + /** Tickets assigned within the time period that are now in progress */ + assigned_today_in_progress: number + /** Leaderboard for tickets closed within the time period */ + helpers_leaderboard: Array + /** Mean time to first helper response for tickets created within the time period that are currently unresolved */ + mean_hang_time_minutes_unresolved: number | null + /** Mean time to first helper response for tickets created within the time period */ + mean_hang_time_minutes_all: number | null + /** Mean time to resolution for tickets created within the time period */ + mean_resolution_time_minutes: number | null +} + +interface LeaderboardEntry { + id: number + slack_id: string + count: number +} + +interface OldestUnansweredTicket { + id: number + created_at: string + age_minutes: number + link: string +} +``` + +Note that some fields can be `null` if there are no tickets (or no open/closed/in-progress tickets) +within the time period. diff --git a/nephthys/api/stats.py b/nephthys/api/stats.py index 539f807..fba2a34 100644 --- a/nephthys/api/stats.py +++ b/nephthys/api/stats.py @@ -28,7 +28,7 @@ async def stats(req: Request): } for entry in total_stats.helpers_leaderboard[:3] ], - "average_hang_time_minutes": total_stats.avg_hang_time_minutes, + "average_hang_time_minutes": total_stats.mean_hang_time_minutes_unresolved, "mean_resolution_time_minutes": total_stats.mean_resolution_time_minutes, "oldest_unanswered_ticket_age_minutes": total_stats.oldest_unanswered_ticket_age_minutes, "prev_day_total": prev_day_stats.new_tickets_total, @@ -43,8 +43,8 @@ async def stats(req: Request): } for entry in prev_day_stats.helpers_leaderboard[:3] ], - "prev_day_average_hang_time_minutes": prev_day_stats.avg_hang_time_current_minutes, - "prev_day_average_hang_time_all_minutes": prev_day_stats.avg_hang_time_all_minutes, + "prev_day_average_hang_time_minutes": prev_day_stats.mean_hang_time_minutes_unresolved, + "prev_day_average_hang_time_all_minutes": prev_day_stats.mean_hang_time_minutes_all, "prev_day_mean_resolution_time_minutes": prev_day_stats.mean_resolution_time_minutes, } ) diff --git a/nephthys/api/stats_v2.py b/nephthys/api/stats_v2.py new file mode 100644 index 0000000..e89c16b --- /dev/null +++ b/nephthys/api/stats_v2.py @@ -0,0 +1,40 @@ +from datetime import datetime +from datetime import timedelta + +from starlette.requests import Request +from starlette.responses import JSONResponse + +from nephthys.utils.stats import calculate_daily_stats +from nephthys.utils.stats import calculate_overall_stats + + +async def stats_v2(req: Request): + """Stats endpoint, made as an improvement to /api/stats + + - Originally made to be used for the Flavortown Super Mega Dashboard + - Subject to change depending on what's useful for dashboards + """ + all_time_stats = await calculate_overall_stats() + + now = datetime.now().astimezone() + one_day_ago = now - timedelta(days=1) + current_day_stats = await calculate_daily_stats(one_day_ago, now) + prev_day_stats = await calculate_daily_stats( + one_day_ago - timedelta(days=1), one_day_ago + ) + + seven_days_ago = now - timedelta(days=7) + current_week_stats = await calculate_daily_stats(seven_days_ago, now) + prev_week_stats = await calculate_daily_stats( + seven_days_ago - timedelta(days=7), seven_days_ago + ) + + return JSONResponse( + { + "all_time": all_time_stats.as_dict(), + "past_24h": current_day_stats.as_dict(), + "past_24h_previous": prev_day_stats.as_dict(), + "past_7d": current_week_stats.as_dict(), + "past_7d_previous": prev_week_stats.as_dict(), + } + ) diff --git a/nephthys/utils/starlette.py b/nephthys/utils/starlette.py index ded261a..8573790 100644 --- a/nephthys/utils/starlette.py +++ b/nephthys/utils/starlette.py @@ -15,6 +15,7 @@ from starlette_exporter import PrometheusMiddleware from nephthys.__main__ import main from nephthys.api.stats import stats +from nephthys.api.stats_v2 import stats_v2 from nephthys.api.ticket import ticket_info from nephthys.api.user import user_stats from nephthys.utils.env import env @@ -65,6 +66,7 @@ app = Starlette( Route(path="/", endpoint=root, methods=["GET"]), Route(path="/slack/events", endpoint=endpoint, methods=["POST"]), Route(path="/api/stats", endpoint=stats, methods=["GET"]), + Route(path="/api/stats_v2", endpoint=stats_v2, methods=["GET"]), Route(path="/api/user", endpoint=user_stats, methods=["GET"]), Route(path="/api/ticket", endpoint=ticket_info, methods=["GET"]), Route(path="/health", endpoint=health, methods=["GET"]), diff --git a/nephthys/utils/stats.py b/nephthys/utils/stats.py index 2e03ef7..5052c1d 100644 --- a/nephthys/utils/stats.py +++ b/nephthys/utils/stats.py @@ -5,6 +5,7 @@ from typing import TypedDict from nephthys.utils.env import env from nephthys.utils.old_tickets import get_unanswered_tickets +from nephthys.utils.ticket_methods import get_question_message_link from prisma.enums import TicketStatus from prisma.models import Ticket from prisma.models import User @@ -15,6 +16,13 @@ class LeaderboardEntry(TypedDict): count: int +class OldestUnansweredTicket(TypedDict): + id: int + created_at: str + age_minutes: float + link: str + + @dataclass class OverallStatsResult: tickets_total: int @@ -22,9 +30,32 @@ class OverallStatsResult: tickets_closed: int tickets_in_progress: int helpers_leaderboard: list[LeaderboardEntry] - avg_hang_time_minutes: float | None + mean_hang_time_minutes_unresolved: float | None + mean_hang_time_minutes_all: float | None mean_resolution_time_minutes: float | None - oldest_unanswered_ticket_age_minutes: float | None + oldest_unanswered_ticket: OldestUnansweredTicket | None + + def as_dict(self) -> dict: + # Warning: Changing these keys will break the stats API + # Note: These fields are documented for end users in api.md + return { + "tickets_total": self.tickets_total, + "tickets_open": self.tickets_open, + "tickets_closed": self.tickets_closed, + "tickets_in_progress": self.tickets_in_progress, + "helpers_leaderboard": [ + { + "id": entry["user"].id, + "slack_id": entry["user"].slackId, + "count": entry["count"], + } + for entry in self.helpers_leaderboard + ], + "mean_hang_time_minutes_unresolved": self.mean_hang_time_minutes_unresolved, + "mean_hang_time_minutes_all": self.mean_hang_time_minutes_all, + "mean_resolution_time_minutes": self.mean_resolution_time_minutes, + "oldest_unanswered_ticket": self.oldest_unanswered_ticket, + } def calculate_hang_times( @@ -71,14 +102,23 @@ async def calculate_overall_stats() -> OverallStatsResult: reverse=True, ) - hang_times = calculate_hang_times(tickets, include_closed_tickets=False) + hang_times_unresolved = calculate_hang_times(tickets, include_closed_tickets=False) + hang_times_all = calculate_hang_times(tickets, include_closed_tickets=True) resolution_times = calculate_resolution_times(tickets) oldest_unanswered_tickets = await get_unanswered_tickets() + oldest_unanswered_ticket = ( + oldest_unanswered_tickets[0] if oldest_unanswered_tickets else None + ) now = datetime.now().astimezone() - oldest_unanswered_ticket_age = ( - (now - oldest_unanswered_tickets[0].createdAt).total_seconds() / 60 - if oldest_unanswered_tickets + oldest_unanswered_ticket_info = ( + OldestUnansweredTicket( + id=oldest_unanswered_ticket.id, + created_at=oldest_unanswered_ticket.createdAt.isoformat(), + age_minutes=(now - oldest_unanswered_ticket.createdAt).total_seconds() / 60, + link=get_question_message_link(oldest_unanswered_ticket), + ) + if oldest_unanswered_ticket else None ) @@ -88,11 +128,14 @@ async def calculate_overall_stats() -> OverallStatsResult: tickets_closed=total_closed, tickets_in_progress=total_in_progress, helpers_leaderboard=helpers_leaderboard, - avg_hang_time_minutes=fmean(hang_times) if hang_times else None, + mean_hang_time_minutes_unresolved=fmean(hang_times_unresolved) + if hang_times_unresolved + else None, + mean_hang_time_minutes_all=fmean(hang_times_all) if hang_times_all else None, mean_resolution_time_minutes=fmean(resolution_times) if resolution_times else None, - oldest_unanswered_ticket_age_minutes=oldest_unanswered_ticket_age, + oldest_unanswered_ticket=oldest_unanswered_ticket_info, ) @@ -110,12 +153,36 @@ class DailyStatsResult: assigned_today_in_progress: int helpers_leaderboard: list[LeaderboardEntry] # Mean time to response for tickets created today and currently in-progress - avg_hang_time_current_minutes: float | None + mean_hang_time_minutes_unresolved: float | None # Mean time to response for all tickets created today - avg_hang_time_all_minutes: float | None + mean_hang_time_minutes_all: float | None # Mean time to resolution for tickets created today mean_resolution_time_minutes: float | None + def as_dict(self) -> dict: + # Warning: Changing these keys will break the stats API + # Note: These fields are documented for end users in api.md + return { + "new_tickets_total": self.new_tickets_total, + "new_tickets_now_closed": self.new_tickets_now_closed, + "new_tickets_still_open": self.new_tickets_still_open, + "new_tickets_in_progress": self.new_tickets_in_progress, + "closed_today": self.closed_today, + "closed_today_from_today": self.closed_today_from_today, + "assigned_today_in_progress": self.assigned_today_in_progress, + "helpers_leaderboard": [ + { + "id": entry["user"].id, + "slack_id": entry["user"].slackId, + "count": entry["count"], + } + for entry in self.helpers_leaderboard + ], + "mean_hang_time_minutes_unresolved": self.mean_hang_time_minutes_unresolved, + "mean_hang_time_minutes_all": self.mean_hang_time_minutes_all, + "mean_resolution_time_minutes": self.mean_resolution_time_minutes, + } + async def calculate_daily_stats( start_time: datetime, end_time: datetime @@ -129,13 +196,7 @@ async def calculate_daily_stats( new_tickets_total = len(tickets_created_today) new_tickets_now_closed = len( - [ - t - for t in tickets_created_today - if t.status == TicketStatus.CLOSED - and t.closedAt - and start_time <= t.closedAt < end_time - ] + [t for t in tickets_created_today if t.status == TicketStatus.CLOSED] ) new_tickets_still_open = len( [t for t in tickets_created_today if t.status == TicketStatus.OPEN] @@ -199,7 +260,7 @@ async def calculate_daily_stats( new_tickets_now_closed=new_tickets_now_closed, new_tickets_in_progress=new_tickets_in_progress, new_tickets_still_open=new_tickets_still_open, - avg_hang_time_current_minutes=hang_time_current, - avg_hang_time_all_minutes=hang_time_all, + mean_hang_time_minutes_unresolved=hang_time_current, + mean_hang_time_minutes_all=hang_time_all, mean_resolution_time_minutes=resolution_time, ) diff --git a/nephthys/views/home/components/leaderboards.py b/nephthys/views/home/components/leaderboards.py index 2d322c4..fc3b253 100644 --- a/nephthys/views/home/components/leaderboards.py +++ b/nephthys/views/home/components/leaderboards.py @@ -30,13 +30,13 @@ async def get_leaderboard_view(): prev_day_leaderboard_str = "\n".join(prev_day_leaderboard_lines) avg_hang_time_str = ( - f"{stats.avg_hang_time_minutes:.2f} minutes" - if stats.avg_hang_time_minutes is not None + f"{stats.mean_hang_time_minutes_unresolved:.2f} minutes" + if stats.mean_hang_time_minutes_unresolved is not None else "No hang time data available" ) avg_prev_day_hang_time_str = ( - f"{prev_day.avg_hang_time_current_minutes:.2f} minutes" - if prev_day.avg_hang_time_current_minutes is not None + f"{prev_day.mean_hang_time_minutes_unresolved:.2f} minutes" + if prev_day.mean_hang_time_minutes_unresolved is not None else "No hang time data available" )