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
This commit is contained in:
Mish 2026-02-03 00:53:50 +00:00 committed by GitHub
parent 1dd26d70f4
commit 311c7341a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 205 additions and 26 deletions

76
docs/api.md Normal file
View file

@ -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<LeaderboardEntry>
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<LeaderboardEntry>
/** 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.

View file

@ -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,
}
)

40
nephthys/api/stats_v2.py Normal file
View file

@ -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(),
}
)

View file

@ -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"]),

View file

@ -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,
)

View file

@ -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"
)