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
@@ -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<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.
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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