feat: add prometheus plugin scaffold
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
.idea/
|
||||
.project
|
||||
.settings/
|
||||
build/
|
||||
out/
|
||||
*.iml
|
||||
.DS_Store
|
||||
@@ -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<JavaCompile>().configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
filteringCharset = "UTF-8"
|
||||
filesMatching("plugin.yml") {
|
||||
expand("version" to version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "prometheus-spigot"
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<String, LongAdder> blocksBrokenByMaterial = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, LongAdder> 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<String, LongAdder> 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<String, LongAdder> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user