mirror of
https://github.com/System-End/nephthys.git
synced 2026-04-19 22:05:12 +00:00
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:
parent
1dd26d70f4
commit
311c7341a1
6 changed files with 205 additions and 26 deletions
76
docs/api.md
Normal file
76
docs/api.md
Normal 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.
|
||||
|
|
@ -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
40
nephthys/api/stats_v2.py
Normal 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(),
|
||||
}
|
||||
)
|
||||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue