Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 205 additions & 54 deletions bot/extensions/dashboard_cog.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
from datetime import datetime, tzinfo
import os
import platform
import subprocess
import time
from datetime import date, datetime, timedelta, tzinfo

import dachshund
import discord
from apscheduler.job import Job
from discord import Member, Message
from discord.ext.commands import Cog, Context
from discord import Member, Message, RawReactionActionEvent
from discord.ext.commands import (
CommandInvokeError,
CommandNotFound,
Cog,
Context,
HybridCommandError,
)
from pytz import timezone

from bot.grace import Grace
from bot.models.extensions.widget_state import WidgetState
from bot.services.process_stats_service import ProcessStats

TIMEZONE = timezone("US/Eastern")
STARTED_AT = "_dashboard_started_at"
# Bar charts with a bar per date, which keep only the last DAILY_DAYS days.
DAILY_SOURCES = ("commands_per_day", "reactions_per_day")
DAILY_DAYS = 30
# Dates trimmed each run, so a week of downtime still gets cleaned up.
TRIM_SPAN = 7

# Their commands go to the recent moderation table instead of recent commands.
MODERATION_COGS = {"Moderation"}
ERROR_LENGTH = 300

SUMMARY = """
# Welcome!
Expand All @@ -20,6 +43,81 @@
"""


def _channel_name(message: Message) -> str:
"""The channel a message counts toward, as shown publicly.

A thread counts toward its parent, a DM toward "DM", and a channel
@everyone can't see toward "private", so its name stays hidden.
"""
channel = getattr(message.channel, "parent", None) or message.channel
guild = getattr(channel, "guild", None)

if guild is None:
return "DM"

if not channel.permissions_for(guild.default_role).view_channel:
return "private"

return channel.name


def _commit() -> str:
"""The deployed commit: Railway's, or the local checkout's."""
if sha := os.environ.get("RAILWAY_GIT_COMMIT_SHA"):
return sha[:7]

try:
return subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=2,
check=True,
).stdout.strip()
except (OSError, subprocess.SubprocessError):
return "unknown"


def _today() -> str:
"""Today's date, the day starting at midnight Eastern."""
return datetime.now(TIMEZONE).date().isoformat()


def _emoji_name(payload: RawReactionActionEvent) -> str:
"""A unicode emoji as is, a custom one as `:name:`."""
emoji = payload.emoji

if emoji.is_custom_emoji():
return f":{emoji.name or 'custom'}:"

return str(emoji)


def _is_moderation(ctx: Context) -> bool:
"""Whether `ctx`'s command belongs to a moderation cog."""
cog = getattr(ctx, "cog", None)

return cog is not None and cog.qualified_name in MODERATION_COGS


def _elapsed_ms(ctx: Context) -> float | None:
"""Time since `on_command` saw `ctx`, `None` if it didn't."""
started_at = getattr(ctx, STARTED_AT, None)

if started_at is None:
return None

return round((time.perf_counter() - started_at) * 1000, 1)


def _describe(error: Exception) -> str:
"""`error`'s type and message, unwrapped from discord.py's wrappers."""
while isinstance(error, (CommandInvokeError, HybridCommandError)):
error = error.original

return f"{type(error).__name__}: {error}"[:ERROR_LENGTH]


class DashboardCog(
Cog, name="Dashboard", description="Feeds bot telemetry to the live dashboard"
):
Expand All @@ -28,31 +126,29 @@ class DashboardCog(
def __init__(self, bot: Grace):
self.bot: Grace = bot
self.jobs: list[Job] = []
self.timezone: tzinfo = timezone("US/Eastern")

self._daily_message_count: int = 0
self._minutely_message_count: int = 0
self._minutely_command_count: int = 0
self.timezone: tzinfo = TIMEZONE
self.process = ProcessStats()
self.commit = _commit()
self.gateway_events = 0

def cog_load(self) -> None:
self._load_counts()
dachshund.emit("summary", value=SUMMARY)
self._report_process()

self.jobs.append(
self.bot.scheduler.add_job(self._report_latency, "interval", minutes=1)
)
self.jobs.append(
self.bot.scheduler.add_job(
self._save_counts, "cron", minute="*/1", timezone=self.timezone
)
self.bot.scheduler.add_job(self._report_process, "interval", minutes=1)
)

self._trim_daily_charts()
self.jobs.append(
self.bot.scheduler.add_job(
self._reset_daily_count,
self._trim_daily_charts,
"cron",
hour=0,
minute=0,
second=0,
minute=5,
timezone=self.timezone,
)
)
Expand All @@ -64,36 +160,34 @@ def cog_unload(self) -> None:
def _report_latency(self) -> None:
self.bot.metrics.record_latency(self.bot.latency_ms)

def _load_counts(self) -> None:
self._daily_message_count = self._load_count("daily_message_rate")
self._minutely_message_count = self._load_count("minutely_message_rate")
self._minutely_command_count = self._load_count("minutely_command_rate")

dachshund.emit("daily_message_rate", value=self._daily_message_count)
dachshund.emit("minutely_message_rate", value=self._minutely_message_count)
dachshund.emit("minutely_command_rate", value=self._minutely_command_count)

def _load_count(self, key: str) -> int:
record = WidgetState.find(key)
return int(record.value) if record else 0

def _save_counts(self) -> None:
for key, value in (
("daily_message_rate", self._daily_message_count),
("minutely_message_rate", self._minutely_message_count),
("minutely_command_rate", self._minutely_command_count),
):
record = WidgetState.find(key)
if record is None:
WidgetState.create(key=key, value=str(value))
else:
record.update(value=str(value))

def _reset_daily_count(self) -> None:
self._daily_message_count = 0
dachshund.emit("daily_message_rate", value=self._daily_message_count)

self._save_counts()
def _report_process(self) -> None:
dachshund.emit("process", **self.process.sample())
dachshund.emit("deployment", **self._deployment())

dachshund.emit("gateway_events", value=self.gateway_events)
self.gateway_events = 0

def _trim_daily_charts(self, now: date | None = None) -> None:
"""Remove the bars older than DAILY_DAYS from the per-day charts."""
now = now or date.fromisoformat(_today())

for days_ago in range(DAILY_DAYS + 1, DAILY_DAYS + 1 + TRIM_SPAN):
old = (now - timedelta(days=days_ago)).isoformat()

for source in DAILY_SOURCES:
dachshund.emit(source, date=old, _delete=True)

def _deployment(self) -> dict[str, str]:
started = datetime.fromtimestamp(self.process.started_at, self.timezone)

return {
"environment": self.bot.app.config.current_environment,
"commit": self.commit,
"started": f"{started:%Y-%m-%d %H:%M %Z}",
"uptime": self.process.uptime(),
"discord_py": discord.__version__,
"python": platform.python_version(),
}

def _report_member_count(self) -> None:
guild_id = self.bot.app.config.get("client", "guild_id")
Expand All @@ -110,38 +204,95 @@ async def on_ready(self) -> None:
async def on_member_join(self, _member: Member) -> None:
self._report_member_count()
dachshund.emit("members")
dachshund.emit("member_flow", joined=1, left=0)

@Cog.listener()
async def on_member_remove(self, _member: Member) -> None:
self._report_member_count()
dachshund.emit("members")
dachshund.emit("member_flow", joined=0, left=1)

@Cog.listener()
async def on_message(self, _message: Message) -> None:
self._minutely_message_count += 1
dachshund.emit("minutely_message_rate", value=self._minutely_message_count)
async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
if payload.guild_id is None or (payload.member and payload.member.bot):
return

dachshund.emit("reactions_per_day", date=_today(), value=1)
dachshund.emit("reaction_emojis", emoji=_emoji_name(payload), value=1)

self._daily_message_count += 1
dachshund.emit("daily_message_rate", value=self._daily_message_count)
@Cog.listener()
async def on_socket_event_type(self, _event_type: str) -> None:
self.gateway_events += 1

@Cog.listener()
async def on_message(self, message: Message) -> None:
dachshund.emit(
"weekly_message_counts",
"message",
date=datetime.now(self.timezone).date().isoformat(),
author=None if message.author.bot else str(message.author.id),
channel=_channel_name(message),
value=1,
)

@Cog.listener()
async def on_command(self, ctx: Context) -> None:
setattr(ctx, STARTED_AT, time.perf_counter())

if ctx.command:
dachshund.emit("on_command", name=ctx.command.qualified_name, value=1)
dachshund.emit("commands_per_day", date=_today(), value=1)

@Cog.listener()
async def on_command_completion(self, ctx: Context) -> None:
elapsed_ms = _elapsed_ms(ctx)

self._minutely_command_count += 1
dachshund.emit("minutely_command_rate", value=self._minutely_command_count)
if ctx.command and elapsed_ms is not None:
dachshund.emit(
"command_duration", name=ctx.command.qualified_name, value=elapsed_ms
)

dachshund.emit("command_outcome", failed=0)

# Moderation commands log their own row, with details.
if not _is_moderation(ctx):
self._record_command(ctx, "ok", elapsed_ms)

@Cog.listener()
async def on_command_error(self, ctx: Context, error: Exception) -> None:
name = ctx.command.qualified_name if ctx.command else "unknown"
dachshund.emit("command_error", name=name, value=1)

# Typos of command names would bury the commands actually run.
if isinstance(error, CommandNotFound):
return

# 100 per failure, so the average over a window is the error rate in %.
dachshund.emit("command_outcome", failed=100)

if _is_moderation(ctx):
dachshund.emit(
"recent_moderation",
action=f"{name} (failed)",
member="",
by=str(ctx.author),
details=_describe(error),
)
else:
self._record_command(ctx, "error", _elapsed_ms(ctx), _describe(error))

def _record_command(
self, ctx: Context, status: str, elapsed_ms: float | None, error: str = ""
) -> None:
"""A row of the recent commands table."""
dachshund.emit(
"recent_commands",
command=ctx.command.qualified_name if ctx.command else "unknown",
user=str(ctx.author),
status=status,
ms=elapsed_ms,
error=error,
)

@Cog.listener()
async def on_disconnect(self) -> None:
dachshund.emit("connection_event", type="disconnect", value=1)
Expand Down
17 changes: 17 additions & 0 deletions bot/extensions/moderation_cog.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import logging
from datetime import UTC, datetime

import dachshund

logger = logging.getLogger(__name__)

from discord import Member, Message, Reaction
Expand Down Expand Up @@ -45,6 +47,13 @@ async def purge(
log.add_field("Reason", reason)

await ctx.channel.purge(limit=int(limit) + 1, bulk=True, reason=reason)
dachshund.emit(
"recent_moderation",
action="purge",
member="",
by=str(ctx.author),
details=f"{limit} message(s) in #{ctx.channel} · {reason}",
)
await log.send(self.moderation_channel or ctx.channel)

@Cog.listener()
Expand Down Expand Up @@ -114,6 +123,14 @@ async def on_member_join(self, member) -> None:
f"Your account needs to be {minimum_account_age} days old or more to join the server."
)
await member.guild.kick(user=member, reason="Account age restriction")
dachshund.emit(
"recent_moderation",
action="kick",
member=str(member),
by="auto",
details=f"account {account_age_in_days} day(s) old, "
f"minimum {minimum_account_age}",
)

if self.moderation_channel:
await log.send(self.moderation_channel)
Expand Down
8 changes: 8 additions & 0 deletions bot/extensions/name_moderation_cog.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging

import dachshund
from discord import Member
from discord.errors import Forbidden
from discord.ext.commands import Cog
Expand Down Expand Up @@ -47,6 +48,13 @@ async def on_member_join(self, member: Member) -> None:
try:
good_name = make_random_name()
await member.edit(nick=good_name)
dachshund.emit(
"recent_moderation",
action="rename",
member=name,
by="auto",
details=f"renamed to {good_name}",
)

log = notice("NAME", f"Username of {name} was changed.")
log.add_field(
Expand Down
Loading
Loading