Leveled log-to-channel discord module, written for d.py
Go to file
disqualifier 988464ef12 fix: compare guild by id so an uninitialized int-guild routes correctly
_get_channel compared guild == self.guild; when initialize() was not called, self.guild stays an int while the passed guild is a Guild object, so the comparison was always False and routing silently fell through to the bot.settings lookup instead of using the constructed channel. now compares by .id on both sides.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:46:20 -04:00
src/dpy_logger fix: compare guild by id so an uninitialized int-guild routes correctly 2026-06-28 17:46:20 -04:00
.gitignore init: leveled discord logger 2026-06-24 21:24:39 -04:00
pyproject.toml add package: pyproject + src 2026-06-24 21:36:43 -04:00
README.md init: leveled discord logger 2026-06-24 21:24:39 -04:00

dpy_logger

Leveled Discord channel logger for discord.py. Logs to a channel via embeds at debug / info / success / fail / task / critical levels. Config-free: static identity is injected at construction; per-guild channel routing is read live from bot.settings so it can change at runtime via a command.

Install

requirements.txt:

dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.0

Direct:

pip install "dpy_logger @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_logger.git@v0.1.0"

Requires discord.py (pulled transitively).

Usage

from dpy_logger import DPYLogger

bot.log = DPYLogger(
    bot, guild_id, channel_id,
    colors=log_colors,            # optional; merged over sensible defaults
    pings=authorized_devs,        # mentioned on critical()
    timezone=tz,
    footer=bot_footer,
    avatar=bot_avatar,
)
await bot.log.initialize()        # resolves ids -> objects, call once

await bot.log.info("started")
await bot.log.success("user promoted", action="promote", actor=ctx.author)
await bot.log.critical("db unreachable")   # pings configured devs

Dynamic routing

Pass guild= on any call to log to that guild's configured channel (bot.settings[guild.id]['channels']['logs']) instead of the construction channel. Because it reads bot.settings at call time, a command that updates that setting changes routing with no restart.

Dual sink (Discord + file)

Every call also mirrors to the stdlib logger (getLogger(__name__)), which your app routes to file/console via its root logging config. So one bot.log.info(...) writes to both the Discord channel and your log file.

bot.log = DPYLogger(bot, guild, channel, log_to_file=True)   # default

await bot.log.info("user joined")                 # -> Discord + file
await bot.log.debug("noisy", log_to_file=False)   # -> Discord only
  • log_to_file= at construction sets the default; pass log_to_file=True/False on any call to override for that call.
  • The stdlib emit happens before the Discord send, so the record survives even if Discord fails.
  • Levels map to stdlib: debug→DEBUG, info/success/task→INFO, fail→ERROR, critical→CRITICAL.

Errors

Resolution failures (unresolvable guild/channel, bad config) raise ValueError at initialize/call time — a misconfigured logger should fail loudly at setup. Per-call send failures do not propagate: they fall back to the stdlib logger so a transient Discord failure never breaks the caller's command.

Construction contract

The host injects everything; the lib never imports config:

  • colors (dict, optional) — per-level colors, merged over defaults
  • pings (list of user ids) — mentioned on critical()
  • timezone, footer, avatar — embed identity
  • alert_here (bool) — if no pings are set, critical() falls back to @here only when this is True; otherwise it sends no mention
  • embed_builder (callable, optional) — restyle embeds without subclassing
  • log_to_file (bool, default True) — mirror every call to the stdlib logger

It also expects bot.settings[guild.id]['channels']['logs'] to exist for the dynamic-routing path. A project with a different settings shape should override _get_channel.

Customizing the embed

Three ways, easiest first.

Pass a function — no subclass. The callable receives the logger instance (so it can read self.colors, self.footer, etc.) plus the call args, and returns a discord.Embed. Used for every level including critical:

def my_embed(logger, level, action, actor, details):
    em = discord.Embed(description=details, color=logger.colors[level])
    if level == "critical":
        em.set_thumbnail(url=logger.avatar)
    if actor:
        em.set_author(name=str(actor))
    return em

bot.log = DPYLogger(bot, guild, channel, embed_builder=my_embed)

Override the method — for complex/stateful customization, subclass and override build_embed(level, action, actor, details). Same routing: every level flows through it.

Default — pass neither and you get the standard fielded embed.

Adding a log type

To add new methods (e.g. a feed), subclass and reuse _resolve. This base has no feed by design; a project that wants one adds it in its own local libs/:

class FeedLogger(DPYLogger):
    """project-local: dpy_logger plus a feed-style announcement embed"""

    async def feed(self, log, title, icon=None, guild=None):
        channel = await self._resolve(guild)
        embed = discord.Embed(
            description=log, color=self.colors.get("feed", self.colors["info"])
        ).set_author(name=title, icon_url=icon)
        return await channel.send(embed=embed)

Versioning

Tagged vX.Y.Z. Pin the tag in requirements.txt.