feat: expand metrics collection

This commit is contained in:
dmg
2026-01-22 22:13:12 -05:00
parent 01878de01e
commit 03b2e83274
6 changed files with 510 additions and 1 deletions
@@ -3,8 +3,12 @@ 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.ChunkListener;
import com.prometheus.spigot.listeners.CommandListener;
import com.prometheus.spigot.listeners.ConnectionListener;
import com.prometheus.spigot.listeners.DeathListener;
import com.prometheus.spigot.listeners.MoveListener;
import com.prometheus.spigot.listeners.PluginListener;
import com.prometheus.spigot.metrics.MetricsRegistry;
import com.prometheus.spigot.util.TokenGenerator;
import com.sun.net.httpserver.HttpServer;
@@ -61,6 +65,10 @@ public final class PrometheusSpigotPlugin extends JavaPlugin {
pluginManager.registerEvents(new DeathListener(metricsRegistry), this);
pluginManager.registerEvents(new ChatListener(metricsRegistry), this);
pluginManager.registerEvents(new MoveListener(metricsRegistry, countBlockMovementOnly), this);
pluginManager.registerEvents(new ConnectionListener(metricsRegistry), this);
pluginManager.registerEvents(new CommandListener(metricsRegistry), this);
pluginManager.registerEvents(new ChunkListener(metricsRegistry), this);
pluginManager.registerEvents(new PluginListener(metricsRegistry), this);
}
private void startHttpServer() {
@@ -0,0 +1,28 @@
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.world.ChunkLoadEvent;
import org.bukkit.event.world.ChunkUnloadEvent;
public final class ChunkListener implements Listener {
private final MetricsRegistry registry;
public ChunkListener(MetricsRegistry registry) {
this.registry = registry;
}
@EventHandler
public void onChunkLoad(ChunkLoadEvent event) {
registry.incrementChunkLoads();
if (event.isNewChunk()) {
registry.incrementChunkGenerations();
}
}
@EventHandler
public void onChunkUnload(ChunkUnloadEvent event) {
registry.incrementChunkUnloads();
}
}
@@ -0,0 +1,42 @@
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.PlayerCommandPreprocessEvent;
import org.bukkit.event.server.ServerCommandEvent;
public final class CommandListener implements Listener {
private final MetricsRegistry registry;
public CommandListener(MetricsRegistry registry) {
this.registry = registry;
}
@EventHandler
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
String command = parseCommand(event.getMessage());
registry.incrementCommandExecuted(command, "player");
if (event.isCancelled()) {
registry.incrementCommandBlocked(command);
}
}
@EventHandler
public void onServerCommand(ServerCommandEvent event) {
String command = parseCommand(event.getCommand());
registry.incrementCommandExecuted(command, "console");
}
private String parseCommand(String message) {
String sanitized = message.trim();
if (sanitized.startsWith("/")) {
sanitized = sanitized.substring(1);
}
int spaceIndex = sanitized.indexOf(' ');
if (spaceIndex > -1) {
return sanitized.substring(0, spaceIndex);
}
return sanitized.isEmpty() ? "unknown" : sanitized;
}
}
@@ -0,0 +1,33 @@
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.PlayerJoinEvent;
import org.bukkit.event.player.PlayerLoginEvent;
import org.bukkit.event.player.PlayerQuitEvent;
public final class ConnectionListener implements Listener {
private final MetricsRegistry registry;
public ConnectionListener(MetricsRegistry registry) {
this.registry = registry;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
registry.incrementPlayerJoins();
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
registry.incrementPlayerQuits();
}
@EventHandler
public void onLogin(PlayerLoginEvent event) {
if (event.getResult() != PlayerLoginEvent.Result.ALLOWED) {
registry.incrementPlayerLoginFailed(event.getResult().name());
}
}
}
@@ -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.server.PluginDisableEvent;
import org.bukkit.event.server.PluginEnableEvent;
public final class PluginListener implements Listener {
private final MetricsRegistry registry;
public PluginListener(MetricsRegistry registry) {
this.registry = registry;
}
@EventHandler
public void onEnable(PluginEnableEvent event) {
registry.incrementPluginEnables();
}
@EventHandler
public void onDisable(PluginDisableEvent event) {
registry.incrementPluginDisables();
}
}
@@ -1,15 +1,25 @@
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.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.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 {
@@ -17,17 +27,35 @@ public final class MetricsRegistry {
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 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<String, LongAdder> blocksBrokenByMaterial = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, LongAdder> deathsByCause = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, LongAdder> loginFailuresByReason = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, LongAdder> commandsBlocked = new ConcurrentHashMap<>();
private final ConcurrentHashMap<LabelPair, LongAdder> commandsExecuted = 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() {
@@ -38,6 +66,18 @@ public final class MetricsRegistry {
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());
}
@@ -50,18 +90,67 @@ public final class MetricsRegistry {
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 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());
appendLabeledCounter(builder, "spigot_blocks_broken_total", "Blocks broken by material", "material", blocksBrokenByMaterial);
appendLabeledCounter(builder, "spigot_player_deaths_total", "Player deaths by cause", "cause", deathsByCause);
appendLabeledCounter(builder, "spigot_player_logins_failed_total", "Failed player logins by reason", "reason", loginFailuresByReason);
appendLabeledCounter(builder, "spigot_commands_blocked_total", "Blocked commands by name", "command", commandsBlocked);
appendLabeledCounter(builder, "spigot_commands_executed_total", "Commands executed by source", "command", "source", commandsExecuted);
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);
@@ -87,6 +176,147 @@ public final class MetricsRegistry {
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<String, Long> gcCounts = new HashMap<>();
Map<String, String> 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()) {
appendLabeledCounter(builder, "jvm_gc_pause_seconds_count", "GC pause count", "gc", gcCounts);
}
if (!gcTimes.isEmpty()) {
appendLabeledCounter(builder, "jvm_gc_pause_seconds_total", "Total GC pause time", "gc", gcTimes);
}
}
private void appendEntityMetrics(StringBuilder builder) {
Map<String, Long> entityCounts = 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);
}
}
appendLabeledGauge(builder, "spigot_entities_total", "Entities by type", "type", entityCounts);
}
private void appendTileEntityMetrics(StringBuilder builder) {
if (chunkTileEntitiesMethod == null) {
return;
}
Map<String, Long> tileCounts = new HashMap<>();
for (var world : server.getWorlds()) {
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);
}
}
}
}
appendLabeledGauge(builder, "spigot_tile_entities_total", "Tile entities by type", "type", tileCounts);
}
private void appendPlayerPingMetrics(StringBuilder builder) {
if (playerPingMethod == null) {
return;
}
List<Integer> 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<Integer> 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) {
@@ -101,6 +331,11 @@ public final class MetricsRegistry {
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');
@@ -111,6 +346,18 @@ public final class MetricsRegistry {
builder.append(name).append(' ').append(value).append('\n');
}
private void appendLabeledGauge(StringBuilder builder, String name, String help, String label, Map<String, Long> 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 appendLabeledCounter(StringBuilder builder, String name, String help, String label, Map<String, LongAdder> values) {
appendHelpAndType(builder, name, help, "counter");
values.entrySet().stream()
@@ -123,6 +370,48 @@ public final class MetricsRegistry {
.append('\n'));
}
private void appendLabeledCounter(StringBuilder builder, String name, String help, String label, Map<String, Long> 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 appendLabeledCounter(StringBuilder builder, String name, String help, String label, Map<String, String> 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 appendLabeledCounter(StringBuilder builder, String name, String help, String labelOne, String labelTwo,
Map<LabelPair, LongAdder> 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)
@@ -133,16 +422,97 @@ public final class MetricsRegistry {
.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<String, LongAdder> map, String labelValue) {
String normalized = labelValue.toLowerCase(Locale.ROOT);
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")
@@ -152,4 +522,7 @@ public final class MetricsRegistry {
private String formatDouble(double value) {
return String.format(Locale.ROOT, "%.3f", value);
}
private record LabelPair(String first, String second) {
}
}