From 57b8e4669b8544cb79be7610ce84e0d99db2e43d Mon Sep 17 00:00:00 2001 From: anthorob Date: Tue, 29 Sep 2026 12:02:27 -0400 Subject: [PATCH] Introduce a pluggable logging system with `LogSink` interfaces Added adaptable logging capabilities using `LogSink` implementations, including Log4j, Sentry, and Discord debug channel handlers. Centralized exception handling in `LogHandler`, introducing methods for registering and deregistering custom sinks. Implemented `LoggedException` to encapsulate exceptions with optional context and message, and refactored existing logging logic for modularity, fail-safety, and extensibility. --- .gitignore | 1 - .../bobert/netneutrality/core/LogHandler.java | 105 +++++++++--------- .../core/log/DiscordDebugChannelLogSink.java | 63 +++++++++++ .../netneutrality/core/log/Log4jLogSink.java | 53 +++++++++ .../netneutrality/core/log/LogSink.java | 22 ++++ .../core/log/LoggedException.java | 35 ++++++ .../netneutrality/core/log/SentryLogSink.java | 19 ++++ 7 files changed, 242 insertions(+), 56 deletions(-) create mode 100644 src/main/java/me/bobert/netneutrality/core/log/DiscordDebugChannelLogSink.java create mode 100644 src/main/java/me/bobert/netneutrality/core/log/Log4jLogSink.java create mode 100644 src/main/java/me/bobert/netneutrality/core/log/LogSink.java create mode 100644 src/main/java/me/bobert/netneutrality/core/log/LoggedException.java create mode 100644 src/main/java/me/bobert/netneutrality/core/log/SentryLogSink.java diff --git a/.gitignore b/.gitignore index d457e02..d1450e5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,6 @@ hs_err_pid* .DS_Store # Maven -log/ target/ #log4j diff --git a/src/main/java/me/bobert/netneutrality/core/LogHandler.java b/src/main/java/me/bobert/netneutrality/core/LogHandler.java index 9c33c1e..aca74a3 100644 --- a/src/main/java/me/bobert/netneutrality/core/LogHandler.java +++ b/src/main/java/me/bobert/netneutrality/core/LogHandler.java @@ -5,13 +5,12 @@ package me.bobert.netneutrality.core; * https://discord.gg/qUa6zNFZsd */ -import io.sentry.Sentry; -import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.core.context.Context; -import me.bobert.netneutrality.core.context.TaskContext; -import me.bobert.netneutrality.db.GSettings; +import me.bobert.netneutrality.core.log.DiscordDebugChannelLogSink; +import me.bobert.netneutrality.core.log.Log4jLogSink; +import me.bobert.netneutrality.core.log.LogSink; +import me.bobert.netneutrality.core.log.LoggedException; +import me.bobert.netneutrality.core.log.SentryLogSink; import me.bobert.netneutrality.db.service.GuildServiceProvider; -import me.bobert.netneutrality.db.service.GuildSettingService; import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.utils.MessageFunction; import net.dv8tion.jda.api.entities.Guild; @@ -19,20 +18,52 @@ import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.concurrent.CopyOnWriteArrayList; + public class LogHandler { private static final Logger logger = LogManager.getLogger(LogHandler.class); - private static volatile GuildServiceProvider guildServices; + private static final CopyOnWriteArrayList sinks = new CopyOnWriteArrayList<>(); + private static volatile DiscordDebugChannelLogSink discordSink; + + static { + sinks.add(new Log4jLogSink()); + sinks.add(new SentryLogSink()); + } /** * Binds the guild-service provider used for debug-channel logging. Called once during bootstrap. + * Internally registers (or replaces) the Discord debug-channel {@link LogSink}. * * @param provider the live guild service provider */ public static void bind(GuildServiceProvider provider) { - guildServices = provider; + if (discordSink != null) { + sinks.remove(discordSink); + } + + discordSink = new DiscordDebugChannelLogSink(provider); + sinks.add(discordSink); } + /** + * Registers an additional log destination. Sinks are notified for every exception logged via + * {@link #log(Exception)}. + * + * @param sink the sink to register + */ + public static void addSink(LogSink sink) { + sinks.add(sink); + } + + /** + * Unregisters a previously registered log destination. + * + * @param sink the sink to remove + */ + public static void removeSink(LogSink sink) { + sinks.remove(sink); + } /** * Log an exception accordingly @@ -40,17 +71,15 @@ public class LogHandler { * @param e The exceptions to log */ public static void log(Exception e) { - Sentry.captureException(e); + LoggedException event = LoggedException.of(e); - if (e instanceof ContextException exception) { - logInternally(exception); - tryLogToDebugChannel(exception); - tryGracefulContextError(exception); - - return; + for (LogSink sink : sinks) { + safelyHandle(sink, event); } - logger.error(e.getMessage(), e); + if (e instanceof ContextException exception) { + tryGracefulContextError(exception); + } } public static void log(String s, Exception e) { @@ -71,45 +100,11 @@ public class LogHandler { } - private static void logInternally(ContextException exception) { - Context context = exception.getContext(); - String guildId = context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId(); - - logger.error("Exception thrown from context (guild: {}, user: {}): {}", guildId, - context.getDiscordUser().getId(), - exception.getMessage() - ); - - if (context instanceof CommandContext commandContext) { - logger.error("Caused by command: {}", commandContext.getCommand().getCommandName(), exception); - } - - if (context instanceof TaskContext taskContext) { - logger.error("Caused by task with ID: {}", taskContext.getId(), exception); - } - } - - private static void tryLogToDebugChannel(ContextException exception) { - Context context = exception.getContext(); - - if (context.getGuild() != null) { - GuildServiceProvider provider = guildServices; - if (provider != null && provider.get(context.getGuild().getId()).isDebugEnabled()) { - GuildSettingService settingService = provider.get(context.getGuild().getId()).getGuildSettingService(); - LogSeverity configured = LogSeverity.parse(settingService.getOrDefault(GSettings.LOG_LEVEL)); - - if (!LogSeverity.ERROR.meetsThreshold(configured)) { - return; - } - - String timezone = settingService.getOrDefault(GSettings.TIMEZONE); - context.getGuild() - .getTextChannels() - .stream() - .filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log")) - .filter(TextChannel::canTalk) - .forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception, timezone)).queue()); - } + private static void safelyHandle(LogSink sink, LoggedException event) { + try { + sink.handle(event); + } catch (Throwable t) { + logger.error("Log sink {} failed to handle exception", sink.getClass().getSimpleName(), t); } } diff --git a/src/main/java/me/bobert/netneutrality/core/log/DiscordDebugChannelLogSink.java b/src/main/java/me/bobert/netneutrality/core/log/DiscordDebugChannelLogSink.java new file mode 100644 index 0000000..9cb061d --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/log/DiscordDebugChannelLogSink.java @@ -0,0 +1,63 @@ +package me.bobert.netneutrality.core.log; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.LogSeverity; +import me.bobert.netneutrality.core.context.Context; +import me.bobert.netneutrality.db.GSettings; +import me.bobert.netneutrality.db.service.GuildServiceProvider; +import me.bobert.netneutrality.db.service.GuildSettingService; +import me.bobert.netneutrality.exceptions.ContextException; +import me.bobert.netneutrality.utils.MessageFunction; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +/** + * A {@link LogSink} that posts {@link ContextException}s to a guild's debug/log channel, subject to + * {@link GuildServiceProvider#isDebugEnabled} and the guild's configured {@link LogSeverity} threshold. + */ +public class DiscordDebugChannelLogSink implements LogSink { + + private final GuildServiceProvider guildServices; + + /** + * @param guildServices the guild service provider used to look up debug settings per guild + */ + public DiscordDebugChannelLogSink(GuildServiceProvider guildServices) { + this.guildServices = guildServices; + } + + @Override + public void handle(LoggedException event) { + if (!(event.exception() instanceof ContextException exception)) { + return; + } + + Context context = exception.getContext(); + + if (context.getGuild() == null) { + return; + } + + if (guildServices == null || !guildServices.get(context.getGuild().getId()).isDebugEnabled()) { + return; + } + + GuildSettingService settingService = guildServices.get(context.getGuild().getId()).getGuildSettingService(); + LogSeverity configured = LogSeverity.parse(settingService.getOrDefault(GSettings.LOG_LEVEL)); + + if (!LogSeverity.ERROR.meetsThreshold(configured)) { + return; + } + + String timezone = settingService.getOrDefault(GSettings.TIMEZONE); + context.getGuild() + .getTextChannels() + .stream() + .filter(textChannel -> textChannel.getName().equals("debug") || textChannel.getName().equals("log")) + .filter(TextChannel::canTalk) + .forEach(textChannel -> textChannel.sendMessageEmbeds(MessageFunction.buildDebugMessage(exception, timezone)).queue()); + } +} diff --git a/src/main/java/me/bobert/netneutrality/core/log/Log4jLogSink.java b/src/main/java/me/bobert/netneutrality/core/log/Log4jLogSink.java new file mode 100644 index 0000000..dee338e --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/log/Log4jLogSink.java @@ -0,0 +1,53 @@ +package me.bobert.netneutrality.core.log; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.context.CommandContext; +import me.bobert.netneutrality.core.context.Context; +import me.bobert.netneutrality.core.context.TaskContext; +import me.bobert.netneutrality.exceptions.ContextException; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * A {@link LogSink} that logs every exception via Log4j, including contextual details + * (guild, user, command/task) when the exception is a {@link ContextException}. + */ +public class Log4jLogSink implements LogSink { + + private static final Logger logger = LogManager.getLogger(Log4jLogSink.class); + + @Override + public void handle(LoggedException event) { + Exception exception = event.exception(); + + if (exception instanceof ContextException contextException) { + logInternally(contextException); + + return; + } + + logger.error(exception.getMessage(), exception); + } + + private void logInternally(ContextException exception) { + Context context = exception.getContext(); + String guildId = context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId(); + + logger.error("Exception thrown from context (guild: {}, user: {}): {}", guildId, + context.getDiscordUser().getId(), + exception.getMessage() + ); + + if (context instanceof CommandContext commandContext) { + logger.error("Caused by command: {}", commandContext.getCommand().getCommandName(), exception); + } + + if (context instanceof TaskContext taskContext) { + logger.error("Caused by task with ID: {}", taskContext.getId(), exception); + } + } +} diff --git a/src/main/java/me/bobert/netneutrality/core/log/LogSink.java b/src/main/java/me/bobert/netneutrality/core/log/LogSink.java new file mode 100644 index 0000000..2aefd58 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/log/LogSink.java @@ -0,0 +1,22 @@ +package me.bobert.netneutrality.core.log; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +/** + * A pluggable log destination. Implementations decide internally whether and how to act on a + * {@link LoggedException}. Failures thrown by a sink are caught and isolated by the dispatcher + * (e.g. {@code LogHandler}) so one broken sink never blocks the others. + */ +public interface LogSink { + + /** + * Handles a logged exception event. Implementations should not throw checked exceptions that + * would prevent other sinks from running; any thrown exception is caught by the dispatcher. + * + * @param event the logged exception event + */ + void handle(LoggedException event); +} diff --git a/src/main/java/me/bobert/netneutrality/core/log/LoggedException.java b/src/main/java/me/bobert/netneutrality/core/log/LoggedException.java new file mode 100644 index 0000000..d5827a4 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/log/LoggedException.java @@ -0,0 +1,35 @@ +package me.bobert.netneutrality.core.log; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.context.Context; + +/** + * Immutable event payload dispatched to every registered {@link LogSink}. + * + * @param exception the exception being logged + * @param context the context the exception occurred in, or {@code null} if none + * @param message an optional additional message, or {@code null} if none + */ +public record LoggedException(Exception exception, Context context, String message) { + + /** + * @param e the exception to log + * @return a {@link LoggedException} with no context or message + */ + public static LoggedException of(Exception e) { + return new LoggedException(e, null, null); + } + + /** + * @param message the message to attach to the exception + * @param e the exception to log + * @return a {@link LoggedException} with no context + */ + public static LoggedException of(String message, Exception e) { + return new LoggedException(e, null, message); + } +} diff --git a/src/main/java/me/bobert/netneutrality/core/log/SentryLogSink.java b/src/main/java/me/bobert/netneutrality/core/log/SentryLogSink.java new file mode 100644 index 0000000..5ecadc2 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/log/SentryLogSink.java @@ -0,0 +1,19 @@ +package me.bobert.netneutrality.core.log; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import io.sentry.Sentry; + +/** + * A {@link LogSink} that reports every logged exception to Sentry. + */ +public class SentryLogSink implements LogSink { + + @Override + public void handle(LoggedException event) { + Sentry.captureException(event.exception()); + } +}