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
black>=20.8b1

View file

@ -26,6 +26,7 @@ install_requires =
psutil>=5.7.2
requests>=2.25.1
rich>=6.0.0
sentry_sdk>=0.19.5
structured_config>=4.12
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
console = Console()
install(console=console, show_locals=True)
from tuxbot.core.utils.console import console
def main() -> None:

View file

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

View file

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

View file

@ -1,46 +1,30 @@
import logging
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.i18n import (
Translator,
)
from tuxbot.core.utils.data_manager import cogs_data_path
from .config import DevConfig
from ...core.utils import checks
from ...core.utils.functions.extra import group_extra, ContextPlus
from tuxbot.core.utils import checks
from tuxbot.core.utils.functions.extra import command_extra, ContextPlus
log = logging.getLogger("tuxbot.cogs.Dev")
_ = Translator("Dev", __file__)
class Dev(commands.Cog, name="Dev"):
yt: YouTrack # pylint: disable=invalid-name
def __init__(self, bot: Tux):
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()
async def _issue(self, ctx: ContextPlus):
"""Manage bot issues."""
@_issue.command(name="list", aliases=["liste", "all", "view"])
async def _lang_list(self, ctx: ContextPlus):
pass
async def _crash(self, ctx: ContextPlus, crash_type: str):
if crash_type == "ZeroDivisionError":
await ctx.send(str(5 / 0))
elif crash_type == "TypeError":
await ctx.send(str(int([])))
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 tuxbot.core.bot import Tux
from .logs import Logs, on_error, GatewayHandler
from .logs import Logs, GatewayHandler
from .config import LogsConfig, HAS_MODELS
VersionInfo = namedtuple("VersionInfo", "major minor micro release_level")
@ -24,4 +24,3 @@ def setup(bot: Tux):
handler = GatewayHandler(cog)
logging.getLogger().addHandler(handler)
commands.AutoShardedBot.on_error = on_error

View file

@ -9,6 +9,7 @@ class LogsConfig(Structure):
guilds: str = StrField("")
errors: str = StrField("")
gateway: str = StrField("")
sentryKey: str = StrField("")
extra = {
@ -35,4 +36,8 @@ extra = {
"type": str,
"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 humanize
import psutil
import sentry_sdk
from discord.ext import commands, tasks
from structured_config import ConfigFile
@ -64,7 +65,70 @@ class Logs(commands.Cog, name="Logs"):
self._resumes = []
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)
to_remove = [
index
@ -81,11 +145,6 @@ class Logs(commands.Cog, name="Logs"):
for index in reversed(to_remove):
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):
if ctx.command is None:
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()
async def on_command_completion(self, ctx: ContextPlus):
await self.register_command(ctx)
@ -128,57 +195,6 @@ class Logs(commands.Cog, name="Logs"):
async def on_socket_response(self, msg):
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()
async def on_guild_join(self, guild: discord.guild):
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)
@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)
if not isinstance(
error, (commands.CommandInvokeError, commands.ConversionError)
@ -215,6 +233,11 @@ class Logs(commands.Cog, name="Logs"):
if isinstance(error, (discord.Forbidden, discord.NotFound)):
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.add_field(name="Name", value=ctx.command.qualified_name)
e.add_field(name="Author", value=f"{ctx.author} (ID: {ctx.author.id})")
@ -251,18 +274,10 @@ class Logs(commands.Cog, name="Logs"):
else:
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)
@commands.is_owner()
@ -318,27 +333,3 @@ class Logs(commands.Cog, name="Logs"):
datetime.datetime.now() - self.bot.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 rich import box
from rich.columns import Columns
from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, TextColumn, BarColumn
from rich.progress import Progress
from rich.table import Table
from tortoise import Tortoise
@ -22,7 +21,10 @@ from tuxbot.core.utils.data_manager import (
data_path,
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,
ConfigFile,
search_for,
@ -31,17 +33,14 @@ from .config import (
)
from . import __version__, ExitCodes
from . import exceptions
from .utils.functions.extra import ContextPlus
from .utils.functions.prefix import get_prefixes
log = logging.getLogger("tuxbot")
console = Console()
packages: List[str] = [
"jishaku",
"tuxbot.cogs.Admin",
"tuxbot.cogs.Logs",
# "tuxbot.cogs.Dev",
"tuxbot.cogs.Dev",
"tuxbot.cogs.Utils",
"tuxbot.cogs.Polls",
"tuxbot.cogs.Custom",
@ -51,13 +50,7 @@ packages: List[str] = [
class Tux(commands.AutoShardedBot):
_loading: asyncio.Task
_progress = {
"main": Progress(
TextColumn("[bold blue]{task.fields[task_name]}", justify="right"),
BarColumn(),
),
"tasks": {},
}
_progress = {"tasks": {}, "main": Progress()}
def __init__(self, *args, cli_flags=None, **kwargs):
# by default, if the bot shutdown without any intervention,
@ -162,20 +155,19 @@ class Tux(commands.AutoShardedBot):
last_run=datetime.datetime.timestamp(self.uptime),
)
self._progress["main"].stop_task(self._progress["tasks"]["connecting"])
self._progress["main"].remove_task(
self._progress["tasks"]["connecting"]
)
self._progress["tasks"].pop("connecting")
console.clear()
with self._progress["main"] as progress:
progress.stop_task(self._progress["tasks"]["discord_connecting"])
progress.remove_task(self._progress["tasks"]["discord_connecting"])
self._progress["tasks"].pop("discord_connecting")
self.console.clear()
console.print(
self.console.print(
Panel(f"[bold blue]Tuxbot V{version_info.major}", style="blue"),
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.add_column(
@ -204,8 +196,8 @@ class Tux(commands.AutoShardedBot):
table.add_row(status)
columns.add_renderable(table)
console.print(columns)
console.print()
self.console.print(columns)
self.console.print()
async def is_owner(
self, user: Union[discord.User, discord.Member]
@ -278,29 +270,24 @@ class Tux(commands.AutoShardedBot):
await self.process_commands(message)
async def start(self, token, bot): # pylint: disable=arguments-differ
"""Connect to Discord and start all connections.
Todo: add postgresql connect here
"""
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,
"""Connect to Discord and start all connections."""
with Progress() as progress:
task = progress.add_task(
"Connecting to PostgreSQL...", total=len(self.extensions)
)
models = []
for extension, _ in self.extensions.items():
if extension == "jishaku":
progress.advance(task)
continue
if importlib.import_module(extension).HAS_MODELS:
models.append(f"{extension}.models.__init__")
progress.update(task_id)
progress.advance(task)
await Tortoise.init(
db_url="postgres://{}:{}@{}:{}/{}".format(
self.config.Core.Database.username,
@ -313,17 +300,13 @@ class Tux(commands.AutoShardedBot):
)
await Tortoise.generate_schemas()
self._progress["main"].stop_task(self._progress["tasks"]["connecting"])
self._progress["main"].remove_task(
self._progress["tasks"]["connecting"]
)
self._progress["tasks"].pop("connecting")
with self._progress.get("main") as progress:
task_id = self._progress.get("tasks")[
"connecting"
with self._progress["main"] as progress:
task_id = self._progress["tasks"][
"discord_connecting"
] = progress.add_task(
"connecting", task_name="Connecting to Discord...", start=False
"discord_connecting",
task_name="Connecting to Discord...",
start=False,
)
progress.update(task_id)
await super().start(token, bot=bot)
@ -341,21 +324,22 @@ class Tux(commands.AutoShardedBot):
active=False,
)
for task in self._progress["tasks"]:
self._progress["main"].log("Shutting down", task)
with self._progress["main"] as progress:
for task in self._progress["tasks"]:
progress.log("Shutting down", task)
self._progress["main"].stop_task(self._progress["tasks"][task])
self._progress["main"].remove_task(
self._progress["tasks"]["connecting"]
)
self._progress["main"].stop()
progress.stop_task(self._progress["tasks"][task])
progress.remove_task(self._progress["tasks"][task])
progress.stop()
pending = [
t for t in asyncio.all_tasks() if t is not asyncio.current_task()
]
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()
await asyncio.gather(*pending, return_exceptions=False)

View file

@ -6,10 +6,6 @@ import discord
from discord import Embed
from discord.ext import commands
from rich.console import Console
console = Console()
TOKEN_REPLACEMENT = "" * random.randint(3, 15)
PASSWORD_REPLACEMENT = "" * random.randint(3, 15)
IP_REPLACEMENT = "" * random.randint(3, 15)
@ -130,6 +126,11 @@ class ContextPlus(commands.Context):
def session(self) -> aiohttp.ClientSession:
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):
def __init__(self, function, **kwargs):