From 925a22cf664e9a774db334e15afb8c11d6e13187 Mon Sep 17 00:00:00 2001 From: NONPLAYT <76615486+NONPLAYT@users.noreply.github.com> Date: Fri, 21 Mar 2025 01:20:54 +0300 Subject: [PATCH] add sentry integration --- divinemc-api/build.gradle.kts.patch | 8 + .../features/0007-Pufferfish-Sentry.patch | 60 +++++++ .../pufferfish/sentry/SentryContext.java | 163 ++++++++++++++++++ .../sentry/PufferfishSentryAppender.java | 132 ++++++++++++++ .../pufferfish/sentry/SentryManager.java | 42 +++++ .../org/bxteam/divinemc/DivineConfig.java | 15 ++ 6 files changed, 420 insertions(+) create mode 100644 divinemc-api/paper-patches/features/0007-Pufferfish-Sentry.patch create mode 100644 divinemc-api/src/main/java/gg/pufferfish/pufferfish/sentry/SentryContext.java create mode 100644 divinemc-server/src/main/java/gg/pufferfish/pufferfish/sentry/PufferfishSentryAppender.java create mode 100644 divinemc-server/src/main/java/gg/pufferfish/pufferfish/sentry/SentryManager.java diff --git a/divinemc-api/build.gradle.kts.patch b/divinemc-api/build.gradle.kts.patch index 25b5d9a..2858982 100644 --- a/divinemc-api/build.gradle.kts.patch +++ b/divinemc-api/build.gradle.kts.patch @@ -1,5 +1,13 @@ --- a/purpur-api/build.gradle.kts +++ b/purpur-api/build.gradle.kts +@@ -54,6 +_,7 @@ + api("org.apache.logging.log4j:log4j-api:$log4jVersion") + api("org.slf4j:slf4j-api:$slf4jVersion") + api("com.mojang:brigadier:1.3.10") ++ api("io.sentry:sentry:8.4.0") // DivineMC - Pufferfish: Sentry + + // Deprecate bungeecord-chat in favor of adventure + api("net.md-5:bungeecord-chat:$bungeeCordChatVersion-deprecated+build.19") { @@ -104,17 +_,21 @@ java { srcDir(generatedApiPath) diff --git a/divinemc-api/paper-patches/features/0007-Pufferfish-Sentry.patch b/divinemc-api/paper-patches/features/0007-Pufferfish-Sentry.patch new file mode 100644 index 0000000..0c33e29 --- /dev/null +++ b/divinemc-api/paper-patches/features/0007-Pufferfish-Sentry.patch @@ -0,0 +1,60 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: NONPLAYT <76615486+NONPLAYT@users.noreply.github.com> +Date: Fri, 21 Mar 2025 01:03:40 +0300 +Subject: [PATCH] Pufferfish: Sentry + + +diff --git a/src/main/java/org/bukkit/plugin/SimplePluginManager.java b/src/main/java/org/bukkit/plugin/SimplePluginManager.java +index 9cb0f09b821a4020d17771a5b64ddd53e7d78478..1638548b766460be65c0c008f7f19df1386d2126 100644 +--- a/src/main/java/org/bukkit/plugin/SimplePluginManager.java ++++ b/src/main/java/org/bukkit/plugin/SimplePluginManager.java +@@ -597,7 +597,9 @@ public final class SimplePluginManager implements PluginManager { + + // Paper start + private void handlePluginException(String msg, Throwable ex, Plugin plugin) { ++ gg.pufferfish.pufferfish.sentry.SentryContext.setPluginContext(plugin); // DivineMC - Pufferfish: Sentry + server.getLogger().log(Level.SEVERE, msg, ex); ++ gg.pufferfish.pufferfish.sentry.SentryContext.removePluginContext(); // DivineMC - Pufferfish: Sentry + callEvent(new com.destroystokyo.paper.event.server.ServerExceptionEvent(new com.destroystokyo.paper.exception.ServerPluginEnableDisableException(msg, ex, plugin))); + } + // Paper end +@@ -667,9 +669,11 @@ public final class SimplePluginManager implements PluginManager { + )); + } + } catch (Throwable ex) { ++ gg.pufferfish.pufferfish.sentry.SentryContext.setEventContext(event, registration); // DivineMC - Pufferfish: Sentry + // Paper start - error reporting + String msg = "Could not pass event " + event.getEventName() + " to " + registration.getPlugin().getDescription().getFullName(); + server.getLogger().log(Level.SEVERE, msg, ex); ++ gg.pufferfish.pufferfish.sentry.SentryContext.removeEventContext(); // DivineMC - Pufferfish: Sentry + if (!(event instanceof com.destroystokyo.paper.event.server.ServerExceptionEvent)) { // We don't want to cause an endless event loop + callEvent(new com.destroystokyo.paper.event.server.ServerExceptionEvent(new com.destroystokyo.paper.exception.ServerEventException(msg, ex, registration.getPlugin(), registration.getListener(), event))); + } +diff --git a/src/main/java/org/bukkit/plugin/java/JavaPluginLoader.java b/src/main/java/org/bukkit/plugin/java/JavaPluginLoader.java +index 40350504b5f7a92d834e95bb2e4e4268195ec9e7..1450ce1ce1ecad79dd9514081190df94ceae9d52 100644 +--- a/src/main/java/org/bukkit/plugin/java/JavaPluginLoader.java ++++ b/src/main/java/org/bukkit/plugin/java/JavaPluginLoader.java +@@ -336,7 +336,13 @@ public final class JavaPluginLoader implements PluginLoader { + try { + jPlugin.setEnabled(true); + } catch (Throwable ex) { ++ // DivineMC start - Pufferfish: Sentry ++ gg.pufferfish.pufferfish.sentry.SentryContext.setPluginContext(plugin); + server.getLogger().log(Level.SEVERE, "Error occurred while enabling " + plugin.getDescription().getFullName() + " (Is it up to date?)", ex); ++ gg.pufferfish.pufferfish.sentry.SentryContext.removePluginContext(); ++ this.server.getPluginManager().disablePlugin(jPlugin); ++ return; ++ // DivineMC end - Pufferfish: Sentry + } + + // Perhaps abort here, rather than continue going, but as it stands, +@@ -361,7 +367,9 @@ public final class JavaPluginLoader implements PluginLoader { + try { + jPlugin.setEnabled(false); + } catch (Throwable ex) { ++ gg.pufferfish.pufferfish.sentry.SentryContext.setPluginContext(plugin); // DivineMC - Pufferfish: Sentry + server.getLogger().log(Level.SEVERE, "Error occurred while disabling " + plugin.getDescription().getFullName() + " (Is it up to date?)", ex); ++ gg.pufferfish.pufferfish.sentry.SentryContext.removePluginContext(); // DivineMC - Pufferfish: Sentry + } + + if (cloader instanceof PluginClassLoader) { diff --git a/divinemc-api/src/main/java/gg/pufferfish/pufferfish/sentry/SentryContext.java b/divinemc-api/src/main/java/gg/pufferfish/pufferfish/sentry/SentryContext.java new file mode 100644 index 0000000..f0d8669 --- /dev/null +++ b/divinemc-api/src/main/java/gg/pufferfish/pufferfish/sentry/SentryContext.java @@ -0,0 +1,163 @@ +package gg.pufferfish.pufferfish.sentry; + +import com.google.gson.Gson; +import org.apache.logging.log4j.ThreadContext; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.player.PlayerEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.RegisteredListener; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Map; +import java.util.TreeMap; + +public class SentryContext { + private static final Gson GSON = new Gson(); + + public static void setPluginContext(@Nullable Plugin plugin) { + if (plugin != null) { + ThreadContext.put("pufferfishsentry_pluginname", plugin.getName()); + ThreadContext.put("pufferfishsentry_pluginversion", plugin.getPluginMeta().getVersion()); + } + } + + public static void removePluginContext() { + ThreadContext.remove("pufferfishsentry_pluginname"); + ThreadContext.remove("pufferfishsentry_pluginversion"); + } + + public static void setSenderContext(@Nullable CommandSender sender) { + if (sender != null) { + ThreadContext.put("pufferfishsentry_playername", sender.getName()); + if (sender instanceof Player player) { + ThreadContext.put("pufferfishsentry_playerid", player.getUniqueId().toString()); + } + } + } + + public static void removeSenderContext() { + ThreadContext.remove("pufferfishsentry_playername"); + ThreadContext.remove("pufferfishsentry_playerid"); + } + + public static void setEventContext(Event event, RegisteredListener registration) { + setPluginContext(registration.getPlugin()); + + try { + // Find the player that was involved with this event + Player player = null; + if (event instanceof PlayerEvent) { + player = ((PlayerEvent) event).getPlayer(); + } else { + Class eventClass = event.getClass(); + + Field playerField = null; + + for (Field field : eventClass.getDeclaredFields()) { + if (field.getType().equals(Player.class)) { + playerField = field; + break; + } + } + + if (playerField != null) { + playerField.setAccessible(true); + player = (Player) playerField.get(event); + } + } + + if (player != null) { + setSenderContext(player); + } + } catch (Exception ignored) { + } // We can't really safely log exceptions. + + ThreadContext.put("pufferfishsentry_eventdata", GSON.toJson(serializeFields(event))); + } + + public static void removeEventContext() { + removePluginContext(); + removeSenderContext(); + ThreadContext.remove("pufferfishsentry_eventdata"); + } + + private static Map serializeFields(Object object) { + Map fields = new TreeMap<>(); + fields.put("_class", object.getClass().getName()); + for (Field declaredField : object.getClass().getDeclaredFields()) { + try { + if (Modifier.isStatic(declaredField.getModifiers())) { + continue; + } + + String fieldName = declaredField.getName(); + if (fieldName.equals("handlers")) { + continue; + } + declaredField.setAccessible(true); + Object value = declaredField.get(object); + if (value != null) { + fields.put(fieldName, value.toString()); + } else { + fields.put(fieldName, ""); + } + } catch (Exception ignored) { + } // We can't really safely log exceptions. + } + return fields; + } + + public static class State { + + private Plugin plugin; + private Command command; + private String commandLine; + private Event event; + private RegisteredListener registeredListener; + + public Plugin getPlugin() { + return plugin; + } + + public void setPlugin(Plugin plugin) { + this.plugin = plugin; + } + + public Command getCommand() { + return command; + } + + public void setCommand(Command command) { + this.command = command; + } + + public String getCommandLine() { + return commandLine; + } + + public void setCommandLine(String commandLine) { + this.commandLine = commandLine; + } + + public Event getEvent() { + return event; + } + + public void setEvent(Event event) { + this.event = event; + } + + public RegisteredListener getRegisteredListener() { + return registeredListener; + } + + public void setRegisteredListener(RegisteredListener registeredListener) { + this.registeredListener = registeredListener; + } + } +} diff --git a/divinemc-server/src/main/java/gg/pufferfish/pufferfish/sentry/PufferfishSentryAppender.java b/divinemc-server/src/main/java/gg/pufferfish/pufferfish/sentry/PufferfishSentryAppender.java new file mode 100644 index 0000000..fbc67c6 --- /dev/null +++ b/divinemc-server/src/main/java/gg/pufferfish/pufferfish/sentry/PufferfishSentryAppender.java @@ -0,0 +1,132 @@ +package gg.pufferfish.pufferfish.sentry; + +import com.google.common.reflect.TypeToken; +import com.google.gson.Gson; +import io.sentry.Breadcrumb; +import io.sentry.Sentry; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.protocol.Message; +import io.sentry.protocol.User; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.filter.AbstractFilter; +import org.bxteam.divinemc.DivineConfig; + +import java.util.Map; + +public class PufferfishSentryAppender extends AbstractAppender { + private static final org.apache.logging.log4j.Logger LOGGER = LogManager.getLogger(PufferfishSentryAppender.class.getSimpleName()); + + private static final Gson GSON = new Gson(); + private final Level logLevel; + + public PufferfishSentryAppender(Level logLevel) { + super("PufferfishSentryAdapter", new SentryFilter(), null); + this.logLevel = logLevel; + } + + @Override + public void append(LogEvent logEvent) { + if (logEvent.getLevel().isMoreSpecificThan(logLevel) && (logEvent.getThrown() != null || !DivineConfig.onlyLogThrown)) { + try { + logException(logEvent); + } catch (Exception e) { + LOGGER.warn("Failed to log event with sentry", e); + } + } else { + try { + logBreadcrumb(logEvent); + } catch (Exception e) { + LOGGER.warn("Failed to log event with sentry", e); + } + } + } + + private void logException(LogEvent e) { + SentryEvent event = new SentryEvent(e.getThrown()); + + Message sentryMessage = new Message(); + sentryMessage.setMessage(e.getMessage().getFormattedMessage()); + + event.setThrowable(e.getThrown()); + event.setLevel(getLevel(e.getLevel())); + event.setLogger(e.getLoggerName()); + event.setTransaction(e.getLoggerName()); + event.setExtra("thread_name", e.getThreadName()); + + boolean hasContext = e.getContextData() != null; + + if (hasContext && e.getContextData().containsKey("pufferfishsentry_playerid")) { + User user = new User(); + user.setId(e.getContextData().getValue("pufferfishsentry_playerid")); + user.setUsername(e.getContextData().getValue("pufferfishsentry_playername")); + event.setUser(user); + } + + if (hasContext && e.getContextData().containsKey("pufferfishsentry_pluginname")) { + event.setExtra("plugin.name", e.getContextData().getValue("pufferfishsentry_pluginname")); + event.setExtra("plugin.version", e.getContextData().getValue("pufferfishsentry_pluginversion")); + event.setTransaction(e.getContextData().getValue("pufferfishsentry_pluginname")); + } + + if (hasContext && e.getContextData().containsKey("pufferfishsentry_eventdata")) { + Map eventFields = GSON.fromJson((String) e.getContextData().getValue("pufferfishsentry_eventdata"), new TypeToken>() { + }.getType()); + if (eventFields != null) { + event.setExtra("event", eventFields); + } + } + + Sentry.captureEvent(event); + } + + private void logBreadcrumb(LogEvent e) { + Breadcrumb breadcrumb = new Breadcrumb(); + + breadcrumb.setLevel(getLevel(e.getLevel())); + breadcrumb.setCategory(e.getLoggerName()); + breadcrumb.setType(e.getLoggerName()); + breadcrumb.setMessage(e.getMessage().getFormattedMessage()); + + Sentry.addBreadcrumb(breadcrumb); + } + + private SentryLevel getLevel(Level level) { + return switch (level.getStandardLevel()) { + case TRACE, DEBUG -> SentryLevel.DEBUG; + case WARN -> SentryLevel.WARNING; + case ERROR -> SentryLevel.ERROR; + case FATAL -> SentryLevel.FATAL; + default -> SentryLevel.INFO; + }; + } + + private static class SentryFilter extends AbstractFilter { + + @Override + public Result filter(Logger logger, org.apache.logging.log4j.Level level, Marker marker, String msg, + Object... params) { + return this.filter(logger.getName()); + } + + @Override + public Result filter(Logger logger, org.apache.logging.log4j.Level level, Marker marker, Object msg, Throwable t) { + return this.filter(logger.getName()); + } + + @Override + public Result filter(LogEvent event) { + return this.filter(event == null ? null : event.getLoggerName()); + } + + private Result filter(String loggerName) { + return loggerName != null && loggerName.startsWith("gg.castaway.pufferfish.sentry") ? Result.DENY + : Result.NEUTRAL; + } + } +} diff --git a/divinemc-server/src/main/java/gg/pufferfish/pufferfish/sentry/SentryManager.java b/divinemc-server/src/main/java/gg/pufferfish/pufferfish/sentry/SentryManager.java new file mode 100644 index 0000000..6b2b5a7 --- /dev/null +++ b/divinemc-server/src/main/java/gg/pufferfish/pufferfish/sentry/SentryManager.java @@ -0,0 +1,42 @@ +package gg.pufferfish.pufferfish.sentry; + +import io.sentry.Sentry; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bxteam.divinemc.DivineConfig; + +public class SentryManager { + private static final Logger LOGGER = LogManager.getLogger(SentryManager.class); + private static boolean initialized = false; + + private SentryManager() { + throw new UnsupportedOperationException("This class cannot be instantiated."); + } + + public static synchronized void init(Level logLevel) { + if (initialized) { + return; + } + if (logLevel == null) { + LOGGER.error("Invalid log level, defaulting to WARN."); + logLevel = Level.WARN; + } + try { + initialized = true; + + Sentry.init(options -> { + options.setDsn(DivineConfig.sentryDsn); + options.setMaxBreadcrumbs(100); + }); + + PufferfishSentryAppender appender = new PufferfishSentryAppender(logLevel); + appender.start(); + ((org.apache.logging.log4j.core.Logger) LogManager.getRootLogger()).addAppender(appender); + LOGGER.info("Sentry logging started!"); + } catch (Exception e) { + LOGGER.warn("Failed to initialize sentry!", e); + initialized = false; + } + } +} diff --git a/divinemc-server/src/main/java/org/bxteam/divinemc/DivineConfig.java b/divinemc-server/src/main/java/org/bxteam/divinemc/DivineConfig.java index 52a5daa..6c5dc23 100644 --- a/divinemc-server/src/main/java/org/bxteam/divinemc/DivineConfig.java +++ b/divinemc-server/src/main/java/org/bxteam/divinemc/DivineConfig.java @@ -1,6 +1,7 @@ package org.bxteam.divinemc; import com.google.common.base.Throwables; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bukkit.configuration.ConfigurationSection; @@ -323,6 +324,20 @@ public class DivineConfig { "Enables optimization that will offload much of the computational effort involved with spawning new mobs to a different thread."); } + public static String sentryDsn = ""; + public static String logLevel = "WARN"; + public static boolean onlyLogThrown = true; + private static void sentrySettings() { + sentryDsn = getString("settings.sentry.dsn", sentryDsn, + "The DSN for Sentry, a service that provides real-time crash reporting that helps you monitor and fix crashes in real time. Leave blank to disable. Obtain link at https://sentry.io"); + logLevel = getString("settings.sentry.log-level", logLevel, + "Logs with a level higher than or equal to this level will be recorded."); + onlyLogThrown = getBoolean("settings.sentry.only-log-thrown", onlyLogThrown, + "Only log Throwable exceptions to Sentry."); + + if (sentryDsn != null && !sentryDsn.isBlank()) gg.pufferfish.pufferfish.sentry.SentryManager.init(Level.getLevel(logLevel)); + } + public static boolean disableDisconnectSpam = false; public static boolean connectionFlushQueueRewrite = false; public static boolean gracefulTeleportHandling = false;