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.
This commit is contained in:
anthorob committed 2026-09-29 12:02:27 -04:00
1 parent 60d7a66b42
commit 57b8e4669b
7 files changed
+242 -56

No files matched your search

-1
View File
@@ -39,7 +39,6 @@ hs_err_pid*
.DS_Store .DS_Store
# Maven # Maven
log/
target/ target/
#log4j #log4j
@@ -5,13 +5,12 @@ package me.bobert.netneutrality.core;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import io.sentry.Sentry; import me.bobert.netneutrality.core.log.DiscordDebugChannelLogSink;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.log.Log4jLogSink;
import me.bobert.netneutrality.core.context.Context; import me.bobert.netneutrality.core.log.LogSink;
import me.bobert.netneutrality.core.context.TaskContext; import me.bobert.netneutrality.core.log.LoggedException;
import me.bobert.netneutrality.db.GSettings; import me.bobert.netneutrality.core.log.SentryLogSink;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.exceptions.ContextException; import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.utils.MessageFunction; import me.bobert.netneutrality.utils.MessageFunction;
import net.dv8tion.jda.api.entities.Guild; 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.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import java.util.concurrent.CopyOnWriteArrayList;
public class LogHandler { public class LogHandler {
private static final Logger logger = LogManager.getLogger(LogHandler.class); private static final Logger logger = LogManager.getLogger(LogHandler.class);
private static volatile GuildServiceProvider guildServices; private static final CopyOnWriteArrayList<LogSink> 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. * 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 * @param provider the live guild service provider
*/ */
public static void bind(GuildServiceProvider 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 * Log an exception accordingly
@@ -40,17 +71,15 @@ public class LogHandler {
* @param e The exceptions to log * @param e The exceptions to log
*/ */
public static void log(Exception e) { public static void log(Exception e) {
Sentry.captureException(e); LoggedException event = LoggedException.of(e);
if (e instanceof ContextException exception) { for (LogSink sink : sinks) {
logInternally(exception); safelyHandle(sink, event);
tryLogToDebugChannel(exception);
tryGracefulContextError(exception);
return;
} }
logger.error(e.getMessage(), e); if (e instanceof ContextException exception) {
tryGracefulContextError(exception);
}
} }
public static void log(String s, Exception e) { public static void log(String s, Exception e) {
@@ -71,45 +100,11 @@ public class LogHandler {
} }
private static void logInternally(ContextException exception) { private static void safelyHandle(LogSink sink, LoggedException event) {
Context context = exception.getContext(); try {
String guildId = context.getGuild() == null ? "PrivateMessage" : context.getGuild().getId(); sink.handle(event);
} catch (Throwable t) {
logger.error("Exception thrown from context (guild: {}, user: {}): {}", guildId, logger.error("Log sink {} failed to handle exception", sink.getClass().getSimpleName(), t);
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());
}
} }
} }
@@ -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());
}
}
@@ -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);
}
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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());
}
}