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:
1 parent
60d7a66b42
commit
57b8e4669b
7 files changed
+242
-56
No files matched your search
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user