diff --git a/src/main/java/com/prometheus/spigot/PrometheusSpigotPlugin.java b/src/main/java/com/prometheus/spigot/PrometheusSpigotPlugin.java index 822e4c6..34b12e2 100644 --- a/src/main/java/com/prometheus/spigot/PrometheusSpigotPlugin.java +++ b/src/main/java/com/prometheus/spigot/PrometheusSpigotPlugin.java @@ -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() { diff --git a/src/main/java/com/prometheus/spigot/listeners/ChunkListener.java b/src/main/java/com/prometheus/spigot/listeners/ChunkListener.java new file mode 100644 index 0000000..a336305 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/listeners/ChunkListener.java @@ -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(); + } +} diff --git a/src/main/java/com/prometheus/spigot/listeners/CommandListener.java b/src/main/java/com/prometheus/spigot/listeners/CommandListener.java new file mode 100644 index 0000000..572427b --- /dev/null +++ b/src/main/java/com/prometheus/spigot/listeners/CommandListener.java @@ -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; + } +} diff --git a/src/main/java/com/prometheus/spigot/listeners/ConnectionListener.java b/src/main/java/com/prometheus/spigot/listeners/ConnectionListener.java new file mode 100644 index 0000000..98ca621 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/listeners/ConnectionListener.java @@ -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()); + } + } +} diff --git a/src/main/java/com/prometheus/spigot/listeners/PluginListener.java b/src/main/java/com/prometheus/spigot/listeners/PluginListener.java new file mode 100644 index 0000000..4fde387 --- /dev/null +++ b/src/main/java/com/prometheus/spigot/listeners/PluginListener.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.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(); + } +} diff --git a/src/main/java/com/prometheus/spigot/metrics/MetricsRegistry.java b/src/main/java/com/prometheus/spigot/metrics/MetricsRegistry.java index c69df59..f842de7 100644 --- a/src/main/java/com/prometheus/spigot/metrics/MetricsRegistry.java +++ b/src/main/java/com/prometheus/spigot/metrics/MetricsRegistry.java @@ -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 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<>(); 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 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()) { + 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 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 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 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) { @@ -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 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 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 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 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 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 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) { + } }