commit 01878de01e9c0aee6d50ddb0bb7ed0a234cea164 Author: Dylan Garvis Date: Thu Jan 22 22:05:03 2026 -0500 feat: add prometheus plugin scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bafa35a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +.idea/ +.project +.settings/ +build/ +out/ +*.iml +.DS_Store diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7fc31a3 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + java +} + +group = "com.prometheus" +version = "0.1.0" + +repositories { + mavenCentral() + maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") +} + +dependencies { + compileOnly("org.spigotmc:spigot-api:1.21.1-R0.1-SNAPSHOT") +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" +} + +tasks.processResources { + filteringCharset = "UTF-8" + filesMatching("plugin.yml") { + expand("version" to version) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ea1cda7 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "prometheus-spigot" diff --git a/src/main/java/com/prometheus/spigot/PrometheusSpigotPlugin.java b/src/main/java/com/prometheus/spigot/PrometheusSpigotPlugin.java new file mode 100644 index 0000000..822e4c6 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/PrometheusSpigotPlugin.java @@ -0,0 +1,90 @@ +package com.prometheus.spigot; + +import com.prometheus.spigot.http.MetricsHttpHandler; +import com.prometheus.spigot.listeners.BlockBreakListener; +import com.prometheus.spigot.listeners.ChatListener; +import com.prometheus.spigot.listeners.DeathListener; +import com.prometheus.spigot.listeners.MoveListener; +import com.prometheus.spigot.metrics.MetricsRegistry; +import com.prometheus.spigot.util.TokenGenerator; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.bukkit.plugin.java.JavaPlugin; + +public final class PrometheusSpigotPlugin extends JavaPlugin { + private HttpServer httpServer; + private ExecutorService httpExecutor; + private MetricsRegistry metricsRegistry; + private String bearerToken; + private boolean countBlockMovementOnly; + + @Override + public void onEnable() { + saveDefaultConfig(); + + bearerToken = getConfig().getString("http.bearer_token", "").trim(); + if (bearerToken.isBlank()) { + bearerToken = TokenGenerator.generate(); + getConfig().set("http.bearer_token", bearerToken); + saveConfig(); + } + + boolean enableJvm = getConfig().getBoolean("metrics.enable_jvm", true); + boolean enableProcess = getConfig().getBoolean("metrics.enable_process", true); + countBlockMovementOnly = getConfig().getBoolean("movement.count_block_changes_only", true); + + metricsRegistry = new MetricsRegistry(getServer(), enableJvm, enableProcess); + + registerListeners(); + startHttpServer(); + } + + @Override + public void onDisable() { + if (httpServer != null) { + httpServer.stop(0); + httpServer = null; + } + if (httpExecutor != null) { + httpExecutor.shutdownNow(); + httpExecutor = null; + } + } + + private void registerListeners() { + var pluginManager = getServer().getPluginManager(); + pluginManager.registerEvents(new BlockBreakListener(metricsRegistry), this); + pluginManager.registerEvents(new DeathListener(metricsRegistry), this); + pluginManager.registerEvents(new ChatListener(metricsRegistry), this); + pluginManager.registerEvents(new MoveListener(metricsRegistry, countBlockMovementOnly), this); + } + + private void startHttpServer() { + String bindAddress = getConfig().getString("http.bind_address", "0.0.0.0").trim(); + int port = getConfig().getInt("http.port", 9149); + String path = getConfig().getString("http.path", "/metrics").trim(); + + if (!path.startsWith("/")) { + path = "/" + path; + } + + InetSocketAddress socketAddress = new InetSocketAddress(bindAddress, port); + try { + httpServer = HttpServer.create(socketAddress, 0); + } catch (IOException ex) { + getLogger().severe(String.format(Locale.ROOT, "Failed to start HTTP server on %s:%d", bindAddress, port)); + getLogger().severe(ex.getMessage()); + return; + } + + httpExecutor = Executors.newFixedThreadPool(2); + httpServer.setExecutor(httpExecutor); + httpServer.createContext(path, new MetricsHttpHandler(metricsRegistry, bearerToken)); + httpServer.start(); + getLogger().info(String.format(Locale.ROOT, "Prometheus endpoint listening on http://%s:%d%s", bindAddress, port, path)); + } +} diff --git a/src/main/java/com/prometheus/spigot/http/MetricsHttpHandler.java b/src/main/java/com/prometheus/spigot/http/MetricsHttpHandler.java new file mode 100644 index 0000000..8108a5b --- /dev/null +++ b/src/main/java/com/prometheus/spigot/http/MetricsHttpHandler.java @@ -0,0 +1,49 @@ +package com.prometheus.spigot.http; + +import com.prometheus.spigot.metrics.MetricsRegistry; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public final class MetricsHttpHandler implements HttpHandler { + private static final String CONTENT_TYPE = "text/plain; version=0.0.4"; + private static final String AUTH_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final MetricsRegistry registry; + private final String bearerToken; + + public MetricsHttpHandler(MetricsRegistry registry, String bearerToken) { + this.registry = registry; + this.bearerToken = bearerToken; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!isAuthorized(exchange.getRequestHeaders())) { + byte[] response = "Unauthorized\n".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(401, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + return; + } + + String body = registry.render(); + byte[] response = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", CONTENT_TYPE); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } + + private boolean isAuthorized(Headers headers) { + String authHeader = headers.getFirst(AUTH_HEADER); + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + return false; + } + String token = authHeader.substring(BEARER_PREFIX.length()); + return token.equals(bearerToken); + } +} diff --git a/src/main/java/com/prometheus/spigot/listeners/BlockBreakListener.java b/src/main/java/com/prometheus/spigot/listeners/BlockBreakListener.java new file mode 100644 index 0000000..f508087 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/listeners/BlockBreakListener.java @@ -0,0 +1,19 @@ +package com.prometheus.spigot.listeners; + +import com.prometheus.spigot.metrics.MetricsRegistry; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; + +public final class BlockBreakListener implements Listener { + private final MetricsRegistry registry; + + public BlockBreakListener(MetricsRegistry registry) { + this.registry = registry; + } + + @EventHandler + public void onBlockBreak(BlockBreakEvent event) { + registry.incrementBlocksBroken(event.getBlock().getType()); + } +} diff --git a/src/main/java/com/prometheus/spigot/listeners/ChatListener.java b/src/main/java/com/prometheus/spigot/listeners/ChatListener.java new file mode 100644 index 0000000..29926c7 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/listeners/ChatListener.java @@ -0,0 +1,19 @@ +package com.prometheus.spigot.listeners; + +import com.prometheus.spigot.metrics.MetricsRegistry; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerChatEvent; + +public final class ChatListener implements Listener { + private final MetricsRegistry registry; + + public ChatListener(MetricsRegistry registry) { + this.registry = registry; + } + + @EventHandler + public void onChat(AsyncPlayerChatEvent event) { + registry.incrementChatMessages(); + } +} diff --git a/src/main/java/com/prometheus/spigot/listeners/DeathListener.java b/src/main/java/com/prometheus/spigot/listeners/DeathListener.java new file mode 100644 index 0000000..8ca50db --- /dev/null +++ b/src/main/java/com/prometheus/spigot/listeners/DeathListener.java @@ -0,0 +1,25 @@ +package com.prometheus.spigot.listeners; + +import com.prometheus.spigot.metrics.MetricsRegistry; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.PlayerDeathEvent; + +public final class DeathListener implements Listener { + private final MetricsRegistry registry; + + public DeathListener(MetricsRegistry registry) { + this.registry = registry; + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + EntityDamageEvent lastDamage = event.getEntity().getLastDamageCause(); + if (lastDamage == null) { + registry.incrementPlayerDeath("unknown"); + return; + } + registry.incrementPlayerDeath(lastDamage.getCause()); + } +} diff --git a/src/main/java/com/prometheus/spigot/listeners/MoveListener.java b/src/main/java/com/prometheus/spigot/listeners/MoveListener.java new file mode 100644 index 0000000..8e200a8 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/listeners/MoveListener.java @@ -0,0 +1,24 @@ +package com.prometheus.spigot.listeners; + +import com.prometheus.spigot.metrics.MetricsRegistry; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerMoveEvent; + +public final class MoveListener implements Listener { + private final MetricsRegistry registry; + private final boolean countBlockMovementOnly; + + public MoveListener(MetricsRegistry registry, boolean countBlockMovementOnly) { + this.registry = registry; + this.countBlockMovementOnly = countBlockMovementOnly; + } + + @EventHandler + public void onMove(PlayerMoveEvent event) { + if (countBlockMovementOnly && !event.hasChangedBlock()) { + return; + } + registry.incrementPlayerMovements(); + } +} diff --git a/src/main/java/com/prometheus/spigot/metrics/MetricsRegistry.java b/src/main/java/com/prometheus/spigot/metrics/MetricsRegistry.java new file mode 100644 index 0000000..c69df59 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/metrics/MetricsRegistry.java @@ -0,0 +1,155 @@ +package com.prometheus.spigot.metrics; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryUsage; +import java.lang.management.ThreadMXBean; +import java.util.Comparator; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LongAdder; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.event.entity.EntityDamageEvent; + +public final class MetricsRegistry { + private final Server server; + private final boolean enableJvm; + private final boolean enableProcess; + private final long startTimeMillis; + + private final LongAdder chatMessages = new LongAdder(); + private final LongAdder playerMovements = new LongAdder(); + private final ConcurrentHashMap blocksBrokenByMaterial = new ConcurrentHashMap<>(); + private final ConcurrentHashMap deathsByCause = new ConcurrentHashMap<>(); + + public MetricsRegistry(Server server, boolean enableJvm, boolean enableProcess) { + this.server = server; + this.enableJvm = enableJvm; + this.enableProcess = enableProcess; + this.startTimeMillis = System.currentTimeMillis(); + } + + public void incrementChatMessages() { + chatMessages.increment(); + } + + public void incrementPlayerMovements() { + playerMovements.increment(); + } + + public void incrementBlocksBroken(Material material) { + incrementLabeledCounter(blocksBrokenByMaterial, material.name()); + } + + public void incrementPlayerDeath(EntityDamageEvent.DamageCause cause) { + incrementLabeledCounter(deathsByCause, cause.name()); + } + + public void incrementPlayerDeath(String cause) { + incrementLabeledCounter(deathsByCause, cause); + } + + public String render() { + StringBuilder builder = new StringBuilder(); + + appendCounter(builder, "spigot_chat_messages_total", "Total chat messages sent", chatMessages.sum()); + appendCounter(builder, "spigot_player_movements_total", "Total player movement events", playerMovements.sum()); + + appendLabeledCounter(builder, "spigot_blocks_broken_total", "Blocks broken by material", "material", blocksBrokenByMaterial); + appendLabeledCounter(builder, "spigot_player_deaths_total", "Player deaths by cause", "cause", deathsByCause); + + appendGauge(builder, "spigot_players_online", "Online player count", server.getOnlinePlayers().size()); + appendGauge(builder, "spigot_players_max", "Max player slots", server.getMaxPlayers()); + appendGauge(builder, "spigot_worlds_loaded", "Loaded world count", server.getWorlds().size()); + + if (enableProcess) { + appendProcessMetrics(builder); + } + + if (enableJvm) { + appendJvmMetrics(builder); + } + + return builder.toString(); + } + + private void appendProcessMetrics(StringBuilder builder) { + double uptimeSeconds = (System.currentTimeMillis() - startTimeMillis) / 1000.0; + appendGauge(builder, "process_uptime_seconds", "Process uptime in seconds", formatDouble(uptimeSeconds)); + } + + private void appendJvmMetrics(StringBuilder builder) { + MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); + MemoryUsage nonHeap = ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage(); + appendMemoryMetrics(builder, "heap", heap); + appendMemoryMetrics(builder, "nonheap", nonHeap); + + ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); + appendGauge(builder, "jvm_threads", "Current JVM thread count", threadBean.getThreadCount()); + } + + private void appendMemoryMetrics(StringBuilder builder, String area, MemoryUsage usage) { + appendLabeledGauge(builder, "jvm_memory_used_bytes", "JVM memory used in bytes", "area", area, usage.getUsed()); + if (usage.getMax() >= 0) { + appendLabeledGauge(builder, "jvm_memory_max_bytes", "JVM memory max in bytes", "area", area, usage.getMax()); + } + } + + private void appendCounter(StringBuilder builder, String name, String help, long value) { + appendHelpAndType(builder, name, help, "counter"); + builder.append(name).append(' ').append(value).append('\n'); + } + + private void appendGauge(StringBuilder builder, String name, String help, long value) { + appendHelpAndType(builder, name, help, "gauge"); + builder.append(name).append(' ').append(value).append('\n'); + } + + private void appendGauge(StringBuilder builder, String name, String help, String value) { + appendHelpAndType(builder, name, help, "gauge"); + builder.append(name).append(' ').append(value).append('\n'); + } + + private void appendLabeledCounter(StringBuilder builder, String name, String help, String label, Map values) { + appendHelpAndType(builder, name, help, "counter"); + values.entrySet().stream() + .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) + .forEach(entry -> builder.append(name) + .append('{').append(label).append("=\"") + .append(escapeLabelValue(entry.getKey())) + .append("\"} ") + .append(entry.getValue().sum()) + .append('\n')); + } + + private void appendLabeledGauge(StringBuilder builder, String name, String help, String label, String labelValue, long value) { + appendHelpAndType(builder, name, help, "gauge"); + builder.append(name) + .append('{').append(label).append("=\"") + .append(escapeLabelValue(labelValue)) + .append("\"} ") + .append(value) + .append('\n'); + } + + private void appendHelpAndType(StringBuilder builder, String name, String help, String type) { + builder.append("# HELP ").append(name).append(' ').append(help).append('\n'); + builder.append("# TYPE ").append(name).append(' ').append(type).append('\n'); + } + + private void incrementLabeledCounter(ConcurrentHashMap map, String labelValue) { + String normalized = labelValue.toLowerCase(Locale.ROOT); + map.computeIfAbsent(normalized, key -> new LongAdder()).increment(); + } + + private String escapeLabelValue(String value) { + return value.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\"", "\\\""); + } + + private String formatDouble(double value) { + return String.format(Locale.ROOT, "%.3f", value); + } +} diff --git a/src/main/java/com/prometheus/spigot/util/TokenGenerator.java b/src/main/java/com/prometheus/spigot/util/TokenGenerator.java new file mode 100644 index 0000000..3fd1f42 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/util/TokenGenerator.java @@ -0,0 +1,18 @@ +package com.prometheus.spigot.util; + +import java.security.SecureRandom; +import java.util.Base64; + +public final class TokenGenerator { + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + + private TokenGenerator() { + } + + public static String generate() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return ENCODER.encodeToString(bytes); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..5f5c0a5 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,10 @@ +http: + bind_address: "0.0.0.0" + port: 9149 + path: "/metrics" + bearer_token: "" +metrics: + enable_jvm: true + enable_process: true +movement: + count_block_changes_only: true diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..370c045 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: PrometheusSpigot +main: com.prometheus.spigot.PrometheusSpigotPlugin +version: ${version} +api-version: "1.21" +description: Exposes a Prometheus metrics endpoint. +author: OpenCode