feat(core|logs>sentry): feat sentry error handler

This commit is contained in:
Romain J 2021-01-27 15:14:05 +01:00
parent 554c0b52d5
commit 647cc4bd64
11 changed files with 151 additions and 202 deletions

View file

@ -1,3 +1,2 @@
youtrack
pylint>=2.6.0 pylint>=2.6.0
black>=20.8b1 black>=20.8b1

View file

@ -26,6 +26,7 @@ install_requires =
psutil>=5.7.2 psutil>=5.7.2
requests>=2.25.1 requests>=2.25.1
rich>=6.0.0 rich>=6.0.0
sentry_sdk>=0.19.5
structured_config>=4.12 structured_config>=4.12
tortoise-orm>=0.16.17 tortoise-orm>=0.16.17

View file

@ -1,9 +1,5 @@
from rich.console import Console
from rich.traceback import install
from tuxbot import ExitCodes from tuxbot import ExitCodes
from tuxbot.core.utils.console import console
console = Console()
install(console=console, show_locals=True)
def main() -> None: def main() -> None:

View file

@ -11,9 +11,7 @@ import discord
import humanize import humanize
import pip import pip
from rich.columns import Columns from rich.columns import Columns
from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.traceback import install
from rich.table import Table, box from rich.table import Table, box
from rich.text import Text from rich.text import Text
from rich import print as rprint from rich import print as rprint
@ -21,14 +19,12 @@ from rich import print as rprint
import tuxbot.logging import tuxbot.logging
from tuxbot.core.bot import Tux from tuxbot.core.bot import Tux
from tuxbot.core import config from tuxbot.core import config
from .core.utils import data_manager from tuxbot.core.utils import data_manager
from tuxbot.core.utils.console import console
from . import __version__, version_info, ExitCodes from . import __version__, version_info, ExitCodes
log = logging.getLogger("tuxbot.main") log = logging.getLogger("tuxbot.main")
console = Console()
install(console=console, show_locals=True)
BORDER_STYLE = "not dim" BORDER_STYLE = "not dim"

View file

@ -4,19 +4,12 @@ HAS_MODELS = False
class DevConfig(Structure): class DevConfig(Structure):
url: str = StrField("") sentryKey: str = StrField("")
login: str = StrField("")
password: str = StrField("")
extra = { extra = {
"url": { "sentryKey": {
"type": str, "type": str,
"description": "URL of the YouTrack instance (without /youtrack/)", "description": "Sentry KEY for error logging (https://sentry.io/)",
},
"login": {"type": str, "description": "Login for YouTrack instance"},
"password": {
"type": str,
"description": "Password for YouTrack instance",
}, },
} }

View file

@ -1,46 +1,30 @@
import logging import logging
from discord.ext import commands from discord.ext import commands
from youtrack.connection import Connection as YouTrack
from structured_config import ConfigFile
from tuxbot.core.bot import Tux from tuxbot.core.bot import Tux
from tuxbot.core.i18n import ( from tuxbot.core.i18n import (
Translator, Translator,
) )
from tuxbot.core.utils.data_manager import cogs_data_path from tuxbot.core.utils import checks
from .config import DevConfig from tuxbot.core.utils.functions.extra import command_extra, ContextPlus
from ...core.utils import checks
from ...core.utils.functions.extra import group_extra, ContextPlus
log = logging.getLogger("tuxbot.cogs.Dev") log = logging.getLogger("tuxbot.cogs.Dev")
_ = Translator("Dev", __file__) _ = Translator("Dev", __file__)
class Dev(commands.Cog, name="Dev"): class Dev(commands.Cog, name="Dev"):
yt: YouTrack # pylint: disable=invalid-name
def __init__(self, bot: Tux): def __init__(self, bot: Tux):
self.bot = bot self.bot = bot
self.__config: DevConfig = ConfigFile(
str(cogs_data_path(self.bot.instance_name, "Dev") / "config.yaml"),
DevConfig,
).config
# pylint: disable=invalid-name
self.yt = YouTrack(
self.__config.url.rstrip("/") + "/youtrack/",
login=self.__config.login,
password=self.__config.password,
)
# ========================================================================= # =========================================================================
# ========================================================================= # =========================================================================
@group_extra(name="issue", aliases=["issues"], deletable=True) @command_extra(name="crash", deletable=True)
@checks.is_owner() @checks.is_owner()
async def _issue(self, ctx: ContextPlus): async def _crash(self, ctx: ContextPlus, crash_type: str):
"""Manage bot issues.""" if crash_type == "ZeroDivisionError":
await ctx.send(str(5 / 0))
@_issue.command(name="list", aliases=["liste", "all", "view"]) elif crash_type == "TypeError":
async def _lang_list(self, ctx: ContextPlus): await ctx.send(str(int([])))
pass elif crash_type == "IndexError":
await ctx.send(str([0][5]))

View file

@ -4,7 +4,7 @@ from collections import namedtuple
from discord.ext import commands from discord.ext import commands
from tuxbot.core.bot import Tux from tuxbot.core.bot import Tux
from .logs import Logs, on_error, GatewayHandler from .logs import Logs, GatewayHandler
from .config import LogsConfig, HAS_MODELS from .config import LogsConfig, HAS_MODELS
VersionInfo = namedtuple("VersionInfo", "major minor micro release_level") VersionInfo = namedtuple("VersionInfo", "major minor micro release_level")
@ -24,4 +24,3 @@ def setup(bot: Tux):
handler = GatewayHandler(cog) handler = GatewayHandler(cog)
logging.getLogger().addHandler(handler) logging.getLogger().addHandler(handler)
commands.AutoShardedBot.on_error = on_error

View file

@ -9,6 +9,7 @@ class LogsConfig(Structure):
guilds: str = StrField("") guilds: str = StrField("")
errors: str = StrField("") errors: str = StrField("")
gateway: str = StrField("") gateway: str = StrField("")
sentryKey: str = StrField("")
extra = { extra = {
@ -35,4 +36,8 @@ extra = {
"type": str, "type": str,
"description": "URL of the webhook used for send gateway information", "description": "URL of the webhook used for send gateway information",
}, },
"sentryKey": {
"type": str,
"description": "Sentry KEY for error logging (https://sentry.io/)",
},
} }

View file

@ -10,6 +10,7 @@ from logging import LogRecord
import discord import discord
import humanize import humanize
import psutil import psutil
import sentry_sdk
from discord.ext import commands, tasks from discord.ext import commands, tasks
from structured_config import ConfigFile from structured_config import ConfigFile
@ -64,7 +65,70 @@ class Logs(commands.Cog, name="Logs"):
self._resumes = [] self._resumes = []
self._identifies = defaultdict(list) self._identifies = defaultdict(list)
def _clear_gateway_data(self): self.old_on_error = bot.on_error
bot.on_error = self.on_error
sentry_sdk.init(
dsn=self.__config.sentryKey,
traces_sample_rate=1.0,
environment=self.bot.instance_name,
debug=False,
)
def cog_unload(self):
self.bot.on_error = self.old_on_error
async def on_error(self, event):
raise event
# =========================================================================
# =========================================================================
def webhook(self, log_type):
webhook = discord.Webhook.from_url(
getattr(self.__config, log_type),
adapter=discord.AsyncWebhookAdapter(self.bot.session),
)
return webhook
async def send_guild_stats(self, e, guild):
e.add_field(name="Name", value=guild.name)
e.add_field(name="ID", value=guild.id)
e.add_field(name="Shard ID", value=guild.shard_id or "N/A")
e.add_field(
name="Owner", value=f"{guild.owner} (ID: {guild.owner.id})"
)
bots = sum(member.bot for member in guild.members)
total = guild.member_count
online = sum(
member.status is discord.Status.online for member in guild.members
)
e.add_field(name="Members", value=str(total))
e.add_field(name="Bots", value=f"{bots} ({bots / total:.2%})")
e.add_field(name="Online", value=f"{online} ({online / total:.2%})")
if guild.icon:
e.set_thumbnail(url=guild.icon_url)
if guild.me:
e.timestamp = guild.me.joined_at
await self.webhook("guilds").send(embed=e)
def add_record(self, record: LogRecord):
self._gateway_queue.put_nowait(record)
async def notify_gateway_status(self, record: LogRecord):
types = {"INFO": ":information_source:", "WARNING": ":warning:"}
emoji = types.get(record.levelname, ":heavy_multiplication_x:")
dt = datetime.datetime.utcfromtimestamp(record.created)
msg = f"{emoji} `[{dt:%Y-%m-%d %H:%M:%S}] {record.message}`"
await self.webhook("gateway").send(msg)
def clear_gateway_data(self):
one_week_ago = datetime.datetime.utcnow() - datetime.timedelta(days=7) one_week_ago = datetime.datetime.utcnow() - datetime.timedelta(days=7)
to_remove = [ to_remove = [
index index
@ -81,11 +145,6 @@ class Logs(commands.Cog, name="Logs"):
for index in reversed(to_remove): for index in reversed(to_remove):
del dates[index] del dates[index]
@tasks.loop(seconds=0.0)
async def gateway_worker(self):
record = await self._gateway_queue.get()
await self.notify_gateway_status(record)
async def register_command(self, ctx: ContextPlus): async def register_command(self, ctx: ContextPlus):
if ctx.command is None: if ctx.command is None:
return return
@ -120,6 +179,14 @@ class Logs(commands.Cog, name="Logs"):
} }
) )
# =========================================================================
# =========================================================================
@tasks.loop(seconds=0.0)
async def gateway_worker(self):
record = await self._gateway_queue.get()
await self.notify_gateway_status(record)
@commands.Cog.listener() @commands.Cog.listener()
async def on_command_completion(self, ctx: ContextPlus): async def on_command_completion(self, ctx: ContextPlus):
await self.register_command(ctx) await self.register_command(ctx)
@ -128,57 +195,6 @@ class Logs(commands.Cog, name="Logs"):
async def on_socket_response(self, msg): async def on_socket_response(self, msg):
self.bot.stats["socket"][msg.get("t")] += 1 self.bot.stats["socket"][msg.get("t")] += 1
def webhook(self, log_type):
webhook = discord.Webhook.from_url(
getattr(self.__config, log_type),
adapter=discord.AsyncWebhookAdapter(self.bot.session),
)
return webhook
async def log_error(self, *, ctx: ContextPlus = None, extra=None):
e = discord.Embed(title="Error", colour=0xDD5F53)
e.description = f"```py\n{traceback.format_exc()}\n```"
e.add_field(name="Extra", value=extra, inline=False)
e.timestamp = datetime.datetime.utcnow()
if ctx is not None:
fmt = "{0} (ID: {0.id})"
author = fmt.format(ctx.author)
channel = fmt.format(ctx.channel)
guild = "None" if ctx.guild is None else fmt.format(ctx.guild)
e.add_field(name="Author", value=author)
e.add_field(name="Channel", value=channel)
e.add_field(name="Guild", value=guild)
await self.webhook("errors").send(embed=e)
async def send_guild_stats(self, e, guild):
e.add_field(name="Name", value=guild.name)
e.add_field(name="ID", value=guild.id)
e.add_field(name="Shard ID", value=guild.shard_id or "N/A")
e.add_field(
name="Owner", value=f"{guild.owner} (ID: {guild.owner.id})"
)
bots = sum(member.bot for member in guild.members)
total = guild.member_count
online = sum(
member.status is discord.Status.online for member in guild.members
)
e.add_field(name="Members", value=str(total))
e.add_field(name="Bots", value=f"{bots} ({bots / total:.2%})")
e.add_field(name="Online", value=f"{online} ({online / total:.2%})")
if guild.icon:
e.set_thumbnail(url=guild.icon_url)
if guild.me:
e.timestamp = guild.me.joined_at
await self.webhook("guilds").send(embed=e)
@commands.Cog.listener() @commands.Cog.listener()
async def on_guild_join(self, guild: discord.guild): async def on_guild_join(self, guild: discord.guild):
e = discord.Embed(colour=0x53DDA4, title="New Guild") # green colour e = discord.Embed(colour=0x53DDA4, title="New Guild") # green colour
@ -204,7 +220,9 @@ class Logs(commands.Cog, name="Logs"):
await self.webhook("dm").send(embed=e) await self.webhook("dm").send(embed=e)
@commands.Cog.listener() @commands.Cog.listener()
async def on_command_error(self, ctx: ContextPlus, error): async def on_command_error(
self, ctx: ContextPlus, error: commands.CommandError
):
await self.register_command(ctx) await self.register_command(ctx)
if not isinstance( if not isinstance(
error, (commands.CommandInvokeError, commands.ConversionError) error, (commands.CommandInvokeError, commands.ConversionError)
@ -215,6 +233,11 @@ class Logs(commands.Cog, name="Logs"):
if isinstance(error, (discord.Forbidden, discord.NotFound)): if isinstance(error, (discord.Forbidden, discord.NotFound)):
return return
sentry_sdk.capture_exception(error)
self.bot.console.log(
"Command Error, check sentry or discord error channel"
)
e = discord.Embed(title="Command Error", colour=0xCC3366) e = discord.Embed(title="Command Error", colour=0xCC3366)
e.add_field(name="Name", value=ctx.command.qualified_name) e.add_field(name="Name", value=ctx.command.qualified_name)
e.add_field(name="Author", value=f"{ctx.author} (ID: {ctx.author.id})") e.add_field(name="Author", value=f"{ctx.author} (ID: {ctx.author.id})")
@ -251,18 +274,10 @@ class Logs(commands.Cog, name="Logs"):
else: else:
self._resumes.append(datetime.datetime.utcnow()) self._resumes.append(datetime.datetime.utcnow())
self._clear_gateway_data() self.clear_gateway_data()
def add_record(self, record: LogRecord): # =========================================================================
self._gateway_queue.put_nowait(record) # =========================================================================
async def notify_gateway_status(self, record: LogRecord):
types = {"INFO": ":information_source:", "WARNING": ":warning:"}
emoji = types.get(record.levelname, ":heavy_multiplication_x:")
dt = datetime.datetime.utcfromtimestamp(record.created)
msg = f"{emoji} `[{dt:%Y-%m-%d %H:%M:%S}] {record.message}`"
await self.webhook("gateway").send(msg)
@command_extra(name="commandstats", hidden=True, deletable=True) @command_extra(name="commandstats", hidden=True, deletable=True)
@commands.is_owner() @commands.is_owner()
@ -318,27 +333,3 @@ class Logs(commands.Cog, name="Logs"):
datetime.datetime.now() - self.bot.uptime datetime.datetime.now() - self.bot.uptime
) )
await ctx.send(f"Uptime: **{uptime}**") await ctx.send(f"Uptime: **{uptime}**")
async def on_error(self, event, *args):
e = discord.Embed(title="Event Error", colour=0xA32952)
e.add_field(name="Event", value=event)
e.description = f"```py\n{traceback.format_exc()}\n```"
e.timestamp = datetime.datetime.utcnow()
args_str = ["```py"]
for index, arg in enumerate(args):
args_str.append(f"[{index}]: {arg!r}")
args_str.append("```")
e.add_field(name="Args", value="\n".join(args_str), inline=False)
hook = self.get_cog("Logs").webhook("errors")
try:
await hook.send(embed=e)
except (
discord.HTTPException,
discord.NotFound,
discord.Forbidden,
discord.InvalidArgument,
):
pass

View file

@ -10,9 +10,8 @@ import discord
from discord.ext import commands from discord.ext import commands
from rich import box from rich import box
from rich.columns import Columns from rich.columns import Columns
from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.progress import Progress, TextColumn, BarColumn from rich.progress import Progress
from rich.table import Table from rich.table import Table
from tortoise import Tortoise from tortoise import Tortoise
@ -22,7 +21,10 @@ from tuxbot.core.utils.data_manager import (
data_path, data_path,
config_dir, config_dir,
) )
from .config import ( from tuxbot.core.utils.functions.extra import ContextPlus
from tuxbot.core.utils.functions.prefix import get_prefixes
from tuxbot.core.utils.console import console
from tuxbot.core.config import (
Config, Config,
ConfigFile, ConfigFile,
search_for, search_for,
@ -31,17 +33,14 @@ from .config import (
) )
from . import __version__, ExitCodes from . import __version__, ExitCodes
from . import exceptions from . import exceptions
from .utils.functions.extra import ContextPlus
from .utils.functions.prefix import get_prefixes
log = logging.getLogger("tuxbot") log = logging.getLogger("tuxbot")
console = Console()
packages: List[str] = [ packages: List[str] = [
"jishaku", "jishaku",
"tuxbot.cogs.Admin", "tuxbot.cogs.Admin",
"tuxbot.cogs.Logs", "tuxbot.cogs.Logs",
# "tuxbot.cogs.Dev", "tuxbot.cogs.Dev",
"tuxbot.cogs.Utils", "tuxbot.cogs.Utils",
"tuxbot.cogs.Polls", "tuxbot.cogs.Polls",
"tuxbot.cogs.Custom", "tuxbot.cogs.Custom",
@ -51,13 +50,7 @@ packages: List[str] = [
class Tux(commands.AutoShardedBot): class Tux(commands.AutoShardedBot):
_loading: asyncio.Task _loading: asyncio.Task
_progress = { _progress = {"tasks": {}, "main": Progress()}
"main": Progress(
TextColumn("[bold blue]{task.fields[task_name]}", justify="right"),
BarColumn(),
),
"tasks": {},
}
def __init__(self, *args, cli_flags=None, **kwargs): def __init__(self, *args, cli_flags=None, **kwargs):
# by default, if the bot shutdown without any intervention, # by default, if the bot shutdown without any intervention,
@ -162,20 +155,19 @@ class Tux(commands.AutoShardedBot):
last_run=datetime.datetime.timestamp(self.uptime), last_run=datetime.datetime.timestamp(self.uptime),
) )
self._progress["main"].stop_task(self._progress["tasks"]["connecting"]) with self._progress["main"] as progress:
self._progress["main"].remove_task( progress.stop_task(self._progress["tasks"]["discord_connecting"])
self._progress["tasks"]["connecting"] progress.remove_task(self._progress["tasks"]["discord_connecting"])
) self._progress["tasks"].pop("discord_connecting")
self._progress["tasks"].pop("connecting") self.console.clear()
console.clear()
console.print( self.console.print(
Panel(f"[bold blue]Tuxbot V{version_info.major}", style="blue"), Panel(f"[bold blue]Tuxbot V{version_info.major}", style="blue"),
justify="center", justify="center",
) )
console.print() self.console.print()
columns = Columns(expand=True, align="center") columns = Columns(align="center", expand=True)
table = Table(style="dim", border_style="not dim", box=box.HEAVY_HEAD) table = Table(style="dim", border_style="not dim", box=box.HEAVY_HEAD)
table.add_column( table.add_column(
@ -204,8 +196,8 @@ class Tux(commands.AutoShardedBot):
table.add_row(status) table.add_row(status)
columns.add_renderable(table) columns.add_renderable(table)
console.print(columns) self.console.print(columns)
console.print() self.console.print()
async def is_owner( async def is_owner(
self, user: Union[discord.User, discord.Member] self, user: Union[discord.User, discord.Member]
@ -278,29 +270,24 @@ class Tux(commands.AutoShardedBot):
await self.process_commands(message) await self.process_commands(message)
async def start(self, token, bot): # pylint: disable=arguments-differ async def start(self, token, bot): # pylint: disable=arguments-differ
"""Connect to Discord and start all connections. """Connect to Discord and start all connections."""
with Progress() as progress:
Todo: add postgresql connect here task = progress.add_task(
""" "Connecting to PostgreSQL...", total=len(self.extensions)
with self._progress.get("main") as progress:
task_id = self._progress.get("tasks")[
"connecting"
] = progress.add_task(
"connecting",
task_name="Connecting to PostgreSQL...",
start=False,
) )
models = [] models = []
for extension, _ in self.extensions.items(): for extension, _ in self.extensions.items():
if extension == "jishaku": if extension == "jishaku":
progress.advance(task)
continue continue
if importlib.import_module(extension).HAS_MODELS: if importlib.import_module(extension).HAS_MODELS:
models.append(f"{extension}.models.__init__") models.append(f"{extension}.models.__init__")
progress.update(task_id) progress.advance(task)
await Tortoise.init( await Tortoise.init(
db_url="postgres://{}:{}@{}:{}/{}".format( db_url="postgres://{}:{}@{}:{}/{}".format(
self.config.Core.Database.username, self.config.Core.Database.username,
@ -313,17 +300,13 @@ class Tux(commands.AutoShardedBot):
) )
await Tortoise.generate_schemas() await Tortoise.generate_schemas()
self._progress["main"].stop_task(self._progress["tasks"]["connecting"]) with self._progress["main"] as progress:
self._progress["main"].remove_task( task_id = self._progress["tasks"][
self._progress["tasks"]["connecting"] "discord_connecting"
)
self._progress["tasks"].pop("connecting")
with self._progress.get("main") as progress:
task_id = self._progress.get("tasks")[
"connecting"
] = progress.add_task( ] = progress.add_task(
"connecting", task_name="Connecting to Discord...", start=False "discord_connecting",
task_name="Connecting to Discord...",
start=False,
) )
progress.update(task_id) progress.update(task_id)
await super().start(token, bot=bot) await super().start(token, bot=bot)
@ -341,21 +324,22 @@ class Tux(commands.AutoShardedBot):
active=False, active=False,
) )
for task in self._progress["tasks"]: with self._progress["main"] as progress:
self._progress["main"].log("Shutting down", task) for task in self._progress["tasks"]:
progress.log("Shutting down", task)
self._progress["main"].stop_task(self._progress["tasks"][task]) progress.stop_task(self._progress["tasks"][task])
self._progress["main"].remove_task( progress.remove_task(self._progress["tasks"][task])
self._progress["tasks"]["connecting"] progress.stop()
)
self._progress["main"].stop()
pending = [ pending = [
t for t in asyncio.all_tasks() if t is not asyncio.current_task() t for t in asyncio.all_tasks() if t is not asyncio.current_task()
] ]
for task in pending: for task in pending:
console.log("Canceling", task.get_name(), f"({task.get_coro()})") self.console.log(
"Canceling", task.get_name(), f"({task.get_coro()})"
)
task.cancel() task.cancel()
await asyncio.gather(*pending, return_exceptions=False) await asyncio.gather(*pending, return_exceptions=False)

View file

@ -6,10 +6,6 @@ import discord
from discord import Embed from discord import Embed
from discord.ext import commands from discord.ext import commands
from rich.console import Console
console = Console()
TOKEN_REPLACEMENT = "" * random.randint(3, 15) TOKEN_REPLACEMENT = "" * random.randint(3, 15)
PASSWORD_REPLACEMENT = "" * random.randint(3, 15) PASSWORD_REPLACEMENT = "" * random.randint(3, 15)
IP_REPLACEMENT = "" * random.randint(3, 15) IP_REPLACEMENT = "" * random.randint(3, 15)
@ -130,6 +126,11 @@ class ContextPlus(commands.Context):
def session(self) -> aiohttp.ClientSession: def session(self) -> aiohttp.ClientSession:
return self.bot.session return self.bot.session
def __repr__(self):
items = ("%s = %r" % (k, v) for k, v in self.__dict__.items())
return "<%s: {%s}>" % (self.__class__.__name__, ", ".join(items))
class CommandPLus(commands.Command): class CommandPLus(commands.Command):
def __init__(self, function, **kwargs): def __init__(self, function, **kwargs):