package com.prometheus.spigot.metrics; import java.lang.management.GarbageCollectorMXBean; import java.lang.management.ManagementFactory; import java.lang.management.MemoryUsage; import java.lang.management.ThreadMXBean; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.LongAdder; import org.bukkit.Chunk; import org.bukkit.Material; import org.bukkit.Server; import org.bukkit.block.BlockState; import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; 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 Method serverTpsMethod; private final Method serverMsptMethod; private final Method playerPingMethod; private final Method chunkTileEntitiesMethod; private volatile Map entityCountsSnapshot = Collections.emptyMap(); private volatile Map tileEntityCountsSnapshot = Collections.emptyMap(); private final LongAdder chatMessages = new LongAdder(); private final LongAdder playerMovements = new LongAdder(); private final LongAdder playerJoins = new LongAdder(); private final LongAdder playerQuits = new LongAdder(); private final LongAdder chunkLoads = new LongAdder(); private final LongAdder chunkUnloads = new LongAdder(); private final LongAdder chunkGenerations = new LongAdder(); private final LongAdder pluginEnables = new LongAdder(); private final LongAdder pluginDisables = new LongAdder(); private final ConcurrentHashMap blocksPlacedByMaterial = new ConcurrentHashMap<>(); private final ConcurrentHashMap blocksBrokenByMaterial = new ConcurrentHashMap<>(); private final ConcurrentHashMap deathsByCause = new ConcurrentHashMap<>(); private final ConcurrentHashMap loginFailuresByReason = new ConcurrentHashMap<>(); private final ConcurrentHashMap commandsBlocked = new ConcurrentHashMap<>(); private final ConcurrentHashMap commandsExecuted = new ConcurrentHashMap<>(); private final ConcurrentHashMap itemsCraftedByMaterial = new ConcurrentHashMap<>(); private final ConcurrentHashMap entityKills = new ConcurrentHashMap<>(); public MetricsRegistry(Server server, boolean enableJvm, boolean enableProcess) { this.server = server; this.enableJvm = enableJvm; this.enableProcess = enableProcess; this.startTimeMillis = System.currentTimeMillis(); this.serverTpsMethod = findMethod(server.getClass(), "getTPS"); this.serverMsptMethod = findMethod(server.getClass(), "getAverageTickTime"); this.playerPingMethod = findMethod(Player.class, "getPing"); this.chunkTileEntitiesMethod = findMethod(Chunk.class, "getTileEntities"); } public void incrementChatMessages() { chatMessages.increment(); } public void incrementPlayerMovements() { playerMovements.increment(); } public void incrementPlayerJoins() { playerJoins.increment(); } public void incrementPlayerQuits() { playerQuits.increment(); } public void incrementPlayerLoginFailed(String reason) { incrementLabeledCounter(loginFailuresByReason, reason); } public void incrementBlocksBroken(Material material) { incrementLabeledCounter(blocksBrokenByMaterial, material.name()); } public void incrementBlocksPlaced(Material material) { incrementLabeledCounter(blocksPlacedByMaterial, material.name()); } public void incrementPlayerDeath(EntityDamageEvent.DamageCause cause) { incrementLabeledCounter(deathsByCause, cause.name()); } public void incrementPlayerDeath(String cause) { incrementLabeledCounter(deathsByCause, cause); } public void incrementChunkLoads() { chunkLoads.increment(); } public void incrementChunkUnloads() { chunkUnloads.increment(); } public void incrementChunkGenerations() { chunkGenerations.increment(); } public void incrementCommandExecuted(String command, String source) { String normalizedCommand = normalizeLabel(command); String normalizedSource = normalizeLabel(source); commandsExecuted.computeIfAbsent(new LabelPair(normalizedCommand, normalizedSource), key -> new LongAdder()).increment(); } public void incrementCommandBlocked(String command) { incrementLabeledCounter(commandsBlocked, command); } public void incrementPluginEnables() { pluginEnables.increment(); } public void incrementPluginDisables() { pluginDisables.increment(); } public void incrementItemsCrafted(Material material) { incrementLabeledCounter(itemsCraftedByMaterial, material.name()); } public void incrementEntityKill(EntityType entityType, String killer) { String normalizedEntity = normalizeLabel(entityType.name()); String normalizedKiller = normalizeLabel(killer); entityKills.computeIfAbsent(new LabelPair(normalizedEntity, normalizedKiller), key -> new LongAdder()).increment(); } public void refreshEntitySnapshots() { Map entityCounts = new HashMap<>(); Map tileCounts = new HashMap<>(); for (var world : server.getWorlds()) { for (Entity entity : world.getEntities()) { EntityType type = entity.getType(); String label = normalizeLabel(type.name()); entityCounts.merge(label, 1L, Long::sum); } if (chunkTileEntitiesMethod != null) { for (Chunk chunk : world.getLoadedChunks()) { Object result = invoke(chunkTileEntitiesMethod, chunk); if (!(result instanceof Object[] states)) { continue; } for (Object state : states) { if (state instanceof BlockState blockState) { String label = normalizeLabel(blockState.getType().name()); tileCounts.merge(label, 1L, Long::sum); } } } } } entityCountsSnapshot = Collections.unmodifiableMap(entityCounts); tileEntityCountsSnapshot = Collections.unmodifiableMap(tileCounts); } 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()); appendCounter(builder, "spigot_players_joined_total", "Total player joins", playerJoins.sum()); appendCounter(builder, "spigot_players_quit_total", "Total player quits", playerQuits.sum()); appendCounter(builder, "spigot_chunk_loads_total", "Total chunk load events", chunkLoads.sum()); appendCounter(builder, "spigot_chunk_unloads_total", "Total chunk unload events", chunkUnloads.sum()); appendCounter(builder, "spigot_chunks_generated_total", "Total generated chunks", chunkGenerations.sum()); appendCounter(builder, "spigot_plugin_enable_total", "Total plugin enable events", pluginEnables.sum()); appendCounter(builder, "spigot_plugin_disable_total", "Total plugin disable events", pluginDisables.sum()); appendLabeledCounterAdder(builder, "spigot_blocks_broken_total", "Blocks broken by material", "material", blocksBrokenByMaterial); appendLabeledCounterAdder(builder, "spigot_blocks_placed_total", "Blocks placed by material", "material", blocksPlacedByMaterial); appendLabeledCounterAdder(builder, "spigot_player_deaths_total", "Player deaths by cause", "cause", deathsByCause); appendLabeledCounterAdder(builder, "spigot_player_logins_failed_total", "Failed player logins by reason", "reason", loginFailuresByReason); appendLabeledCounterAdder(builder, "spigot_commands_blocked_total", "Blocked commands by name", "command", commandsBlocked); appendLabeledCounterAdder(builder, "spigot_commands_executed_total", "Commands executed by source", "command", "source", commandsExecuted); appendLabeledCounterAdder(builder, "spigot_items_crafted_total", "Items crafted by material", "material", itemsCraftedByMaterial); appendLabeledCounterAdder(builder, "spigot_entities_killed_total", "Entities killed by player", "entity", "killer", entityKills); 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()); appendGauge(builder, "spigot_chunks_loaded", "Loaded chunk count", countLoadedChunks()); appendGauge(builder, "spigot_plugins_loaded", "Loaded plugin count", server.getPluginManager().getPlugins().length); appendGauge(builder, "spigot_tasks_pending", "Pending scheduled tasks", server.getScheduler().getPendingTasks().size()); appendGauge(builder, "spigot_tasks_running", "Active scheduled tasks", server.getScheduler().getActiveWorkers().size()); appendEntityMetrics(builder); appendTileEntityMetrics(builder); appendPlayerPingMetrics(builder); appendPerformanceMetrics(builder); 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()); appendThreadCpuMetrics(builder, threadBean); appendGcMetrics(builder); } private void appendThreadCpuMetrics(StringBuilder builder, ThreadMXBean threadBean) { if (!threadBean.isThreadCpuTimeSupported()) { return; } if (!threadBean.isThreadCpuTimeEnabled()) { try { threadBean.setThreadCpuTimeEnabled(true); } catch (SecurityException ex) { return; } } long totalNanos = 0L; for (long threadId : threadBean.getAllThreadIds()) { long time = threadBean.getThreadCpuTime(threadId); if (time > 0) { totalNanos += time; } } double totalSeconds = totalNanos / 1_000_000_000.0; appendCounter(builder, "jvm_thread_cpu_seconds_total", "Total CPU seconds used by JVM threads", formatDouble(totalSeconds)); } private void appendGcMetrics(StringBuilder builder) { Map gcCounts = new HashMap<>(); Map gcTimes = new HashMap<>(); for (GarbageCollectorMXBean gcBean : ManagementFactory.getGarbageCollectorMXBeans()) { String name = normalizeLabel(gcBean.getName()); long count = gcBean.getCollectionCount(); long timeMillis = gcBean.getCollectionTime(); if (count >= 0) { gcCounts.put(name, count); } if (timeMillis >= 0) { double seconds = timeMillis / 1000.0; gcTimes.put(name, formatDouble(seconds)); } } if (!gcCounts.isEmpty()) { appendLabeledCounterLong(builder, "jvm_gc_pause_seconds_count", "GC pause count", "gc", gcCounts); } if (!gcTimes.isEmpty()) { appendLabeledCounterString(builder, "jvm_gc_pause_seconds_total", "Total GC pause time", "gc", gcTimes); } } private void appendEntityMetrics(StringBuilder builder) { appendLabeledGauge(builder, "spigot_entities_total", "Entities by type", "type", entityCountsSnapshot); } private void appendTileEntityMetrics(StringBuilder builder) { if (chunkTileEntitiesMethod == null) { return; } appendLabeledGauge(builder, "spigot_tile_entities_total", "Tile entities by type", "type", tileEntityCountsSnapshot); } private void appendPlayerPingMetrics(StringBuilder builder) { if (playerPingMethod == null) { return; } List pings = new ArrayList<>(); for (Player player : server.getOnlinePlayers()) { Integer ping = readPing(player); if (ping != null) { pings.add(ping); appendLabeledGauge(builder, "spigot_player_ping_ms_player", "Player ping in milliseconds", "player", player.getName(), ping); } } appendPingHistogram(builder, pings); } private void appendPingHistogram(StringBuilder builder, List pings) { double[] buckets = {25, 50, 100, 150, 200, 300, 500, 1000}; long[] bucketCounts = new long[buckets.length + 1]; long total = 0; long sum = 0; for (int ping : pings) { total++; sum += ping; boolean placed = false; for (int i = 0; i < buckets.length; i++) { if (ping <= buckets[i]) { bucketCounts[i]++; placed = true; break; } } if (!placed) { bucketCounts[bucketCounts.length - 1]++; } } appendHelpAndType(builder, "spigot_player_ping_ms", "Player ping histogram", "histogram"); long cumulative = 0; for (int i = 0; i < buckets.length; i++) { cumulative += bucketCounts[i]; appendHistogramBucket(builder, "spigot_player_ping_ms_bucket", buckets[i], cumulative); } cumulative += bucketCounts[bucketCounts.length - 1]; appendHistogramBucket(builder, "spigot_player_ping_ms_bucket", Double.POSITIVE_INFINITY, cumulative); builder.append("spigot_player_ping_ms_count ").append(total).append('\n'); builder.append("spigot_player_ping_ms_sum ").append(sum).append('\n'); } private void appendPerformanceMetrics(StringBuilder builder) { double[] tps = readTps(); if (tps != null && tps.length >= 3) { appendLabeledGauge(builder, "spigot_tps", "Server TPS by interval", "interval", "1m", formatDouble(tps[0])); appendLabeledGauge(builder, "spigot_tps", "Server TPS by interval", "interval", "5m", formatDouble(tps[1])); appendLabeledGauge(builder, "spigot_tps", "Server TPS by interval", "interval", "15m", formatDouble(tps[2])); } Double mspt = readMspt(); if (mspt != null) { appendGauge(builder, "spigot_mspt", "Average milliseconds per tick", formatDouble(mspt)); } } 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 appendCounter(StringBuilder builder, String name, String help, String 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 appendLabeledGauge(StringBuilder builder, String name, String help, String label, Map values) { appendHelpAndType(builder, name, help, "gauge"); 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()) .append('\n')); } private void appendLabeledCounterAdder(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 appendLabeledCounterLong(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()) .append('\n')); } private void appendLabeledCounterString(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()) .append('\n')); } private void appendLabeledCounterAdder(StringBuilder builder, String name, String help, String labelOne, String labelTwo, Map values) { appendHelpAndType(builder, name, help, "counter"); values.entrySet().stream() .sorted(Map.Entry.comparingByKey(Comparator.comparing(LabelPair::first).thenComparing(LabelPair::second))) .forEach(entry -> builder.append(name) .append('{') .append(labelOne).append("=\"") .append(escapeLabelValue(entry.getKey().first())) .append("\",") .append(labelTwo).append("=\"") .append(escapeLabelValue(entry.getKey().second())) .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 appendLabeledGauge(StringBuilder builder, String name, String help, String label, String labelValue, String 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 = normalizeLabel(labelValue); map.computeIfAbsent(normalized, key -> new LongAdder()).increment(); } private long countLoadedChunks() { long total = 0; for (var world : server.getWorlds()) { total += world.getLoadedChunks().length; } return total; } private String normalizeLabel(String labelValue) { String trimmed = labelValue.trim(); return trimmed.isEmpty() ? "unknown" : trimmed.toLowerCase(Locale.ROOT); } private Integer readPing(Player player) { if (playerPingMethod == null) { return null; } Object result = invoke(playerPingMethod, player); if (result instanceof Integer ping) { return ping; } return null; } private double[] readTps() { if (serverTpsMethod == null) { return null; } Object result = invoke(serverTpsMethod, server); if (result instanceof double[] tps) { return tps; } return null; } private Double readMspt() { if (serverMsptMethod == null) { return null; } Object result = invoke(serverMsptMethod, server); if (result instanceof Double value) { return value; } return null; } private Object invoke(Method method, Object target) { try { return method.invoke(target); } catch (ReflectiveOperationException ex) { return null; } } private Method findMethod(Class type, String name) { try { return type.getMethod(name); } catch (NoSuchMethodException ex) { return null; } } private void appendHistogramBucket(StringBuilder builder, String name, double upperBound, long value) { builder.append(name) .append("{le=\"") .append(Double.isInfinite(upperBound) ? "+Inf" : formatDouble(upperBound)) .append("\"} ") .append(value) .append('\n'); } private String escapeLabelValue(String value) { return value.replace("\\", "\\\\") .replace("\n", "\\n") .replace("\"", "\\\""); } private String formatDouble(double value) { return String.format(Locale.ROOT, "%.3f", value); } private record LabelPair(String first, String second) { } }