Skip to content

Commit 3591ef7

Browse files
committed
feat(stats): Implement comprehensive stats tracking for bot and ModMail, including migration script for ModMail stats
1 parent d9b0cfa commit 3591ef7

4 files changed

Lines changed: 402 additions & 140 deletions

File tree

bronxbot.py

Lines changed: 197 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,104 @@
99
import traceback
1010
import signal
1111
import atexit
12+
import requests
1213
from discord.ext import commands, tasks
1314
from typing import Dict, List, Tuple
1415
from os import system
1516
import logging
17+
from datetime import datetime
1618
from utils.command_tracker import usage_tracker
1719
from utils.tos_handler import check_tos_acceptance, prompt_tos_acceptance
1820
from utils.scalability import initialize_scalability
1921

2022
# Set up logging
2123
logging.basicConfig(level=logging.INFO)
2224

25+
class StatsTracker:
26+
def __init__(self, bot, dashboard_url):
27+
self.bot = bot
28+
self.dashboard_url = dashboard_url.rstrip('/')
29+
self.start_time = datetime.now()
30+
self.command_count = 0
31+
self.daily_commands = 0
32+
self.command_types = {}
33+
34+
async def send_stats(self):
35+
"""Send comprehensive stats to dashboard"""
36+
uptime = datetime.now() - self.start_time
37+
38+
stats = {
39+
"uptime": {
40+
"days": uptime.days,
41+
"hours": uptime.seconds // 3600,
42+
"minutes": (uptime.seconds % 3600) // 60,
43+
"total_seconds": uptime.total_seconds(),
44+
"start_time": self.start_time.timestamp()
45+
},
46+
"guilds": {
47+
"count": len(self.bot.guilds),
48+
"list": [str(guild.id) for guild in self.bot.guilds],
49+
"detailed": [
50+
{
51+
"id": str(guild.id),
52+
"name": guild.name,
53+
"member_count": guild.member_count
54+
} for guild in self.bot.guilds
55+
]
56+
},
57+
"performance": {
58+
"user_count": sum(guild.member_count for guild in self.bot.guilds if guild.member_count),
59+
"latency": round(self.bot.latency * 1000, 2),
60+
"shard_count": self.bot.shard_count or 1
61+
},
62+
"commands": {
63+
"total_executed": self.command_count,
64+
"daily_count": self.daily_commands,
65+
"command_types": self.command_types.copy()
66+
}
67+
}
68+
69+
try:
70+
# Use aiohttp for async HTTP requests
71+
async with aiohttp.ClientSession() as session:
72+
async with session.post(
73+
f"{self.dashboard_url}/api/stats",
74+
json=stats,
75+
timeout=10
76+
) as response:
77+
if response.status == 200:
78+
logging.debug("✅ Stats sent to dashboard successfully")
79+
else:
80+
logging.warning(f"❌ Failed to send stats: {response.status}")
81+
except Exception as e:
82+
logging.error(f"❌ Error sending stats: {e}")
83+
84+
async def send_command_update(self, command_name):
85+
"""Send real-time command execution update"""
86+
self.command_count += 1
87+
self.daily_commands += 1
88+
self.command_types[command_name] = self.command_types.get(command_name, 0) + 1
89+
90+
update = {
91+
"type": "command_executed",
92+
"command": command_name,
93+
"total_commands": self.command_count,
94+
"timestamp": time.time()
95+
}
96+
97+
try:
98+
# Use aiohttp for async HTTP requests
99+
async with aiohttp.ClientSession() as session:
100+
async with session.post(
101+
f"{self.dashboard_url}/api/stats/realtime",
102+
json=update,
103+
timeout=5
104+
) as response:
105+
if response.status != 200:
106+
logging.debug(f"Failed to send real-time update: {response.status}")
107+
except Exception as e:
108+
logging.debug(f"Failed to send real-time update: {e}")
109+
23110
def cleanup_resources():
24111
"""Cleanup resources on shutdown"""
25112
try:
@@ -103,116 +190,6 @@ async def load_cog_with_timing(self, cog_name: str) -> Tuple[bool, float]:
103190
self.cog_load_times[cog_name] = load_time
104191
return False, load_time
105192

106-
@tasks.loop(seconds=10) # More frequent updates for better real-time feel
107-
async def update_stats(self):
108-
"""Update bot stats and send to dashboard"""
109-
try:
110-
# Calculate uptime
111-
current_time = time.time()
112-
uptime_seconds = int(current_time - self.start_time)
113-
uptime_days = uptime_seconds // 86400
114-
uptime_hours = (uptime_seconds % 86400) // 3600
115-
uptime_minutes = (uptime_seconds % 3600) // 60
116-
117-
# Get command metrics from tracker if available
118-
command_stats = {}
119-
daily_commands = 0
120-
total_commands = 0
121-
session_stats = {}
122-
123-
try:
124-
# Try to get command stats from tracker
125-
if hasattr(usage_tracker, 'get_daily_stats'):
126-
daily_commands = usage_tracker.get_daily_stats()
127-
if hasattr(usage_tracker, 'get_total_commands'):
128-
total_commands = usage_tracker.get_total_commands()
129-
if hasattr(usage_tracker, 'get_command_breakdown'):
130-
command_stats = usage_tracker.get_command_breakdown()
131-
if hasattr(usage_tracker, 'get_session_stats'):
132-
session_stats = usage_tracker.get_session_stats()
133-
except:
134-
pass
135-
136-
stats = {
137-
'uptime': {
138-
'days': uptime_days,
139-
'hours': uptime_hours,
140-
'minutes': uptime_minutes,
141-
'total_seconds': uptime_seconds,
142-
'start_time': self.start_time
143-
},
144-
'guilds': {
145-
'count': len(self.guilds),
146-
'list': [str(g.id) for g in self.guilds],
147-
'detailed': [
148-
{
149-
'id': str(g.id),
150-
'name': g.name,
151-
'member_count': g.member_count or 0
152-
} for g in self.guilds
153-
]
154-
},
155-
'commands': {
156-
'daily_count': daily_commands,
157-
'total_executed': total_commands,
158-
'command_types': command_stats,
159-
'session': session_stats
160-
},
161-
'performance': {
162-
'latency': round(self.latency * 1000, 2),
163-
'user_count': sum((g.member_count or 0) for g in self.guilds),
164-
'shard_count': self.shard_count or 1
165-
},
166-
'timestamp': current_time
167-
}
168-
169-
# Store stats based on environment
170-
if dev:
171-
# Development: Store in JSON file
172-
with open('data/stats.json', 'w') as f:
173-
json.dump(stats, f, indent=2)
174-
logging.debug("Stats saved to local JSON file")
175-
else:
176-
# Production: Send to database via dashboard API
177-
async with aiohttp.ClientSession() as session:
178-
dashboard_urls = ['https://bronxbot.onrender.com/api/stats/update']
179-
180-
for url in dashboard_urls:
181-
try:
182-
async with session.post(url, json=stats, timeout=10) as resp:
183-
if resp.status == 200:
184-
result = await resp.json()
185-
logging.debug(f"Stats updated successfully to {url}")
186-
else:
187-
error_text = await resp.text()
188-
logging.warning(f"Failed to update stats to {url}: {resp.status} - {error_text}")
189-
190-
except asyncio.TimeoutError:
191-
logging.warning(f"Timeout updating stats to {url}")
192-
except Exception as e:
193-
logging.error(f"Error updating stats to {url}: {e}")
194-
195-
# Always try to update localhost if available (for development testing)
196-
if dev:
197-
try:
198-
async with aiohttp.ClientSession() as session:
199-
async with session.post('http://localhost:5000/api/stats/update',
200-
json=stats, timeout=5) as resp:
201-
if resp.status == 200:
202-
logging.debug("Stats updated to localhost dashboard")
203-
except:
204-
pass # Localhost might not be running
205-
206-
except Exception as e:
207-
logging.error(f"Error in update_stats loop: {e}")
208-
import traceback
209-
traceback.print_exc()
210-
211-
@update_stats.before_loop
212-
async def before_update_stats(self):
213-
"""Wait until the bot is ready before starting the stats update loop"""
214-
await self.wait_until_ready()
215-
216193
@tasks.loop(minutes=5) # Check every 5 minutes, no need to do it as frequently as stats
217194
async def update_guilds(self):
218195
"""Update guild list for the web interface"""
@@ -231,6 +208,33 @@ async def update_guilds(self):
231208
async def before_update_guilds(self):
232209
await self.wait_until_ready()
233210

211+
async def send_realtime_command_update(self, command_name: str, user_id: int, guild_id: int = None, execution_time: float = 0, error: bool = False):
212+
"""Send real-time command update to dashboard"""
213+
try:
214+
update_data = {
215+
'type': 'command_update',
216+
'command': command_name,
217+
'user_id': str(user_id),
218+
'guild_id': str(guild_id) if guild_id else None,
219+
'execution_time': execution_time,
220+
'error': error,
221+
'timestamp': time.time()
222+
}
223+
224+
dashboard_urls = ['https://bronxbot.onrender.com/api/realtime'] if not dev else ['http://localhost:5000/api/realtime']
225+
226+
async with aiohttp.ClientSession() as session:
227+
for url in dashboard_urls:
228+
try:
229+
async with session.post(url, json=update_data, timeout=5) as resp:
230+
if resp.status == 200:
231+
logging.debug("Real-time command update sent successfully")
232+
break
233+
except Exception as e:
234+
logging.debug(f"Failed to send real-time update to {url}: {e}")
235+
except Exception as e:
236+
logging.debug(f"Error sending real-time command update: {e}")
237+
234238
async def close(self):
235239
"""Gracefully close bot connections"""
236240
logging.info("Shutting down bot...")
@@ -244,6 +248,21 @@ async def close(self):
244248
self.update_guilds.stop()
245249
logging.info("Stopped guild update loop")
246250

251+
# Stop additional stats tasks
252+
try:
253+
if additional_stats_update.is_running():
254+
additional_stats_update.stop()
255+
logging.info("Stopped additional stats update loop")
256+
except:
257+
pass
258+
259+
try:
260+
if reset_daily_stats.is_running():
261+
reset_daily_stats.stop()
262+
logging.info("Stopped daily stats reset loop")
263+
except:
264+
pass
265+
247266
# Shutdown scalability manager
248267
if hasattr(self, 'scalability_manager') and self.scalability_manager:
249268
await self.scalability_manager.shutdown()
@@ -274,6 +293,10 @@ async def close(self):
274293
)
275294
bot.remove_command('help')
276295

296+
# Initialize stats tracker
297+
dashboard_url = "https://bronxbot.onrender.com" if not dev else "http://localhost:5000"
298+
stats_tracker = StatsTracker(bot, dashboard_url)
299+
277300
# loading config
278301
COG_DATA = {
279302
"cogs": {
@@ -513,6 +536,44 @@ async def on_ready():
513536
name=f"with {len(bot.guilds)} servers | .help"
514537
)
515538
await bot.change_presence(activity=activity)
539+
540+
# Start additional stats tracker
541+
if not additional_stats_update.is_running():
542+
additional_stats_update.start()
543+
logging.info("Started additional stats update loop")
544+
545+
if not reset_daily_stats.is_running():
546+
reset_daily_stats.start()
547+
logging.info("Started daily stats reset loop")
548+
549+
# Additional stats tasks
550+
@tasks.loop(minutes=5) # Send stats every 5 minutes
551+
async def additional_stats_update():
552+
"""Send comprehensive stats to dashboard"""
553+
try:
554+
await stats_tracker.send_stats()
555+
except Exception as e:
556+
logging.error(f"Error in additional stats update: {e}")
557+
558+
@additional_stats_update.before_loop
559+
async def before_additional_stats_update():
560+
"""Wait until the bot is ready before starting the additional stats update loop"""
561+
await bot.wait_until_ready()
562+
563+
# Reset daily commands at midnight
564+
@tasks.loop(hours=24)
565+
async def reset_daily_stats():
566+
"""Reset daily command count"""
567+
try:
568+
stats_tracker.daily_commands = 0
569+
logging.info("Daily stats reset completed")
570+
except Exception as e:
571+
logging.error(f"Error resetting daily stats: {e}")
572+
573+
@reset_daily_stats.before_loop
574+
async def before_reset_daily_stats():
575+
"""Wait until the bot is ready before starting the daily reset loop"""
576+
await bot.wait_until_ready()
516577

517578
@bot.event
518579
async def on_guild_join(guild):
@@ -584,12 +645,37 @@ async def on_command_completion(ctx):
584645
"""Track successful command completion"""
585646
execution_time = time.time() - getattr(ctx, 'command_start_time', time.time())
586647
usage_tracker.track_command(ctx, ctx.command.qualified_name, execution_time, error=False)
648+
649+
# Track command with stats tracker
650+
try:
651+
await stats_tracker.send_command_update(ctx.command.qualified_name)
652+
except Exception as e:
653+
logging.debug(f"Error tracking command with stats tracker: {e}")
654+
655+
# Send real-time update to dashboard
656+
await bot.send_realtime_command_update(
657+
command_name=ctx.command.qualified_name,
658+
user_id=ctx.author.id,
659+
guild_id=ctx.guild.id if ctx.guild else None,
660+
execution_time=execution_time,
661+
error=False
662+
)
587663

588664
@bot.event
589665
async def on_command_error(ctx, error):
590666
"""Track command errors and let Error cog handle the rest"""
591667
execution_time = time.time() - getattr(ctx, 'command_start_time', time.time())
592-
usage_tracker.track_command(ctx, ctx.command.qualified_name if ctx.command else 'unknown', execution_time, error=True)
668+
command_name = ctx.command.qualified_name if ctx.command else 'unknown'
669+
usage_tracker.track_command(ctx, command_name, execution_time, error=True)
670+
671+
# Send real-time error update to dashboard
672+
await bot.send_realtime_command_update(
673+
command_name=command_name,
674+
user_id=ctx.author.id,
675+
guild_id=ctx.guild.id if ctx.guild else None,
676+
execution_time=execution_time,
677+
error=True
678+
)
593679

594680
# Let the Error cog handle most errors first
595681
if hasattr(bot, 'get_cog') and bot.get_cog('Error'):

0 commit comments

Comments
 (0)