Skip to content

Commit 154bf6c

Browse files
cursoragentcalmmage
andcommitted
Add user statistics tracking and admin stats command
Co-authored-by: petr.b.lavrov <petr.b.lavrov@gmail.com>
1 parent fe3aa67 commit 154bf6c

File tree

2 files changed

+156
-0
lines changed

2 files changed

+156
-0
lines changed

src/app.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,3 +1135,94 @@ async def chat_about_transcript(
11351135
)
11361136

11371137
return response
1138+
1139+
async def get_user_statistics(self) -> Dict[str, Any]:
1140+
"""
1141+
Get comprehensive usage statistics for all users.
1142+
1143+
Returns:
1144+
Dictionary with user statistics including request counts, total minutes, and costs
1145+
"""
1146+
# Get all cost entries from the database
1147+
costs = await self.db.costs.find({}).to_list(length=10000)
1148+
events = await self.db.events.find({"event_type": "file_submission"}).to_list(length=10000)
1149+
1150+
# Initialize user stats dictionary
1151+
user_stats = {}
1152+
1153+
# Process cost data
1154+
for cost_entry in costs:
1155+
user_id = cost_entry.get("user_id")
1156+
if not user_id:
1157+
continue
1158+
1159+
if user_id not in user_stats:
1160+
user_stats[user_id] = {
1161+
"total_cost": 0.0,
1162+
"total_requests": 0,
1163+
"total_minutes": 0.0,
1164+
"operations": {},
1165+
"models": {},
1166+
"first_activity": None,
1167+
"last_activity": None,
1168+
}
1169+
1170+
# Add cost
1171+
cost = float(cost_entry.get("cost", 0.0)) if cost_entry.get("cost") is not None else 0.0
1172+
user_stats[user_id]["total_cost"] += cost
1173+
1174+
# Track operation types
1175+
operation = cost_entry.get("operation", "unknown")
1176+
if operation not in user_stats[user_id]["operations"]:
1177+
user_stats[user_id]["operations"][operation] = {"cost": 0.0, "count": 0}
1178+
user_stats[user_id]["operations"][operation]["cost"] += cost
1179+
user_stats[user_id]["operations"][operation]["count"] += 1
1180+
1181+
# Track models used
1182+
model = cost_entry.get("model", "unknown")
1183+
if model not in user_stats[user_id]["models"]:
1184+
user_stats[user_id]["models"][model] = {"cost": 0.0, "count": 0}
1185+
user_stats[user_id]["models"][model]["cost"] += cost
1186+
user_stats[user_id]["models"][model]["count"] += 1
1187+
1188+
# Track timestamps
1189+
timestamp = cost_entry.get("timestamp")
1190+
if timestamp:
1191+
if user_stats[user_id]["first_activity"] is None or timestamp < user_stats[user_id]["first_activity"]:
1192+
user_stats[user_id]["first_activity"] = timestamp
1193+
if user_stats[user_id]["last_activity"] is None or timestamp > user_stats[user_id]["last_activity"]:
1194+
user_stats[user_id]["last_activity"] = timestamp
1195+
1196+
# Extract minutes from usage data
1197+
usage = cost_entry.get("usage", {})
1198+
if isinstance(usage, dict):
1199+
# For transcription operations, get minutes from estimated_minutes or audio_duration_seconds
1200+
if operation == "transcription":
1201+
estimated_minutes = usage.get("estimated_minutes", 0)
1202+
if estimated_minutes:
1203+
user_stats[user_id]["total_minutes"] += float(estimated_minutes)
1204+
elif usage.get("audio_duration_seconds"):
1205+
minutes = float(usage.get("audio_duration_seconds", 0)) / 60
1206+
user_stats[user_id]["total_minutes"] += minutes
1207+
1208+
# Process event data to get request counts
1209+
for event in events:
1210+
user_id = event.get("user_id")
1211+
if user_id and user_id in user_stats:
1212+
user_stats[user_id]["total_requests"] += 1
1213+
1214+
# Calculate summary statistics
1215+
total_users = len(user_stats)
1216+
total_cost_all_users = sum(stats["total_cost"] for stats in user_stats.values())
1217+
total_requests_all_users = sum(stats["total_requests"] for stats in user_stats.values())
1218+
total_minutes_all_users = sum(stats["total_minutes"] for stats in user_stats.values())
1219+
1220+
return {
1221+
"user_stats": user_stats,
1222+
"summary": {
1223+
"total_users": total_users,
1224+
"total_cost": total_cost_all_users,
1225+
"total_requests": total_requests_all_users,
1226+
"total_minutes": total_minutes_all_users,
1227+
}
1228+
}

src/router.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,71 @@ async def help_handler(message: Message, app: App):
6363
await send_safe(message.chat.id, help_message)
6464

6565

66+
@commands_menu.botspot_command("stats", "Show usage statistics (admin only)")
67+
@router.message(Command("stats"))
68+
async def stats_handler(message: Message, app: App):
69+
"""Stats command handler - shows usage statistics for all users"""
70+
assert message.from_user is not None
71+
72+
# TODO: Implement proper admin checking
73+
# For now, you can add admin user IDs to check against
74+
# admin_user_ids = [123456789, 987654321] # Replace with actual admin user IDs
75+
# if message.from_user.id not in admin_user_ids:
76+
# await send_safe(message.chat.id, "❌ This command is only available to administrators.")
77+
# return
78+
79+
# Get user statistics
80+
try:
81+
stats = await app.get_user_statistics()
82+
83+
# Format the statistics nicely
84+
response = "<b>📊 Bot Usage Statistics</b>\n\n"
85+
86+
# Summary section
87+
summary = stats["summary"]
88+
response += f"<b>📈 Summary:</b>\n"
89+
response += f"• Total Users: {summary['total_users']}\n"
90+
response += f"• Total Requests: {summary['total_requests']}\n"
91+
response += f"• Total Audio Minutes: {summary['total_minutes']:.1f}\n"
92+
response += f"• Total Cost: ${summary['total_cost']:.4f}\n\n"
93+
94+
# Per-user statistics
95+
response += "<b>👥 Per-User Statistics:</b>\n"
96+
97+
user_stats = stats["user_stats"]
98+
# Sort users by total cost (descending)
99+
sorted_users = sorted(user_stats.items(), key=lambda x: x[1]["total_cost"], reverse=True)
100+
101+
for username, user_data in sorted_users[:20]: # Show top 20 users
102+
response += f"\n<b>@{username}</b>\n"
103+
response += f" • Requests: {user_data['total_requests']}\n"
104+
response += f" • Audio Minutes: {user_data['total_minutes']:.1f}\n"
105+
response += f" • Total Cost: ${user_data['total_cost']:.4f}\n"
106+
107+
# Show breakdown of operations if available
108+
if user_data["operations"]:
109+
response += f" • Operations: "
110+
op_details = []
111+
for op, op_data in user_data["operations"].items():
112+
op_details.append(f"{op}({op_data['count']})")
113+
response += ", ".join(op_details) + "\n"
114+
115+
# Show activity timeframe
116+
if user_data["first_activity"] and user_data["last_activity"]:
117+
first = user_data["first_activity"]
118+
last = user_data["last_activity"]
119+
response += f" • Active: {first.strftime('%Y-%m-%d')} to {last.strftime('%Y-%m-%d')}\n"
120+
121+
if len(user_stats) > 20:
122+
response += f"\n<i>... and {len(user_stats) - 20} more users</i>"
123+
124+
except Exception as e:
125+
logger.error(f"Error getting statistics: {e}")
126+
response = f"❌ Error retrieving statistics: {str(e)}"
127+
128+
await send_safe(message.chat.id, response)
129+
130+
66131
class Language(BaseModel):
67132
language_code: str
68133

0 commit comments

Comments
 (0)