govno kod
Some checks failed
Build / build (push) Failing after 3h3m20s

This commit is contained in:
2026-05-01 23:51:16 +03:00
commit 6179d4fbc9
906 changed files with 116097 additions and 0 deletions

13
FastStats/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
#IntelliJ
.idea/*
**.iml
#Maven
target/*
#Eclipse
**/.settings/*
**/.project
**/.classpath
/target/

5
FastStats/README.md Normal file
View File

@@ -0,0 +1,5 @@
This subproject contains sources copied from the [FastStats Java SDK](https://github.com/faststats-dev/faststats-java).
The version of the SDK at the time of copying was 0.21.0.
Some changes have been made to get rid of annotations library dependencies, and to support Java 8.
This was necessary to make the integration possible for Minecraft 1.8.8 servers.

74
FastStats/pom.xml Normal file
View File

@@ -0,0 +1,74 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>invsee-plus-plus</artifactId>
<groupId>com.janboerman.invsee</groupId>
<version>0.31.11-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>faststats</artifactId>
<name>FastStats</name>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<release>8</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>${maven-jar-plugin.version}</version>
<configuration>
<archive>
<manifestEntries>
<Multi-Release>true</Multi-Release>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>26.1.1.build.15-alpha</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.janboerman.invsee</groupId>
<artifactId>utils</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,18 @@
package com.janboerman.invsee.faststats;
public final class FastStats {
/** API Token as presented in the 'project settings' on the FastStats website. */
public static final String API_TOKEN = "3e3ed377fb507fb659ff4b33151e055e";
/** Which library are we using to talk to FastStats? */
// We copied the sources from faststats-java: https://github.com/faststats-dev/faststats-java.
public static final String SDK_NAME = "faststats-java";
/** And what version of the library are we using? */
// At the time of copying, version 0.21.0 was the latest released version.
public static final String SDK_VERSION = "0.21.0";
private FastStats() {
}
}

View File

@@ -0,0 +1,177 @@
package com.janboerman.invsee.faststats;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public final class Java8HttpClient {
private final int connectTimeoutMs;
private Java8HttpClient(
Duration connectTimeout
) {
this.connectTimeoutMs = (int)Objects.requireNonNull(connectTimeout).toMillis();
}
public Response send(Request request) throws IOException {
URL url = request.uri.toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod(request.method);
connection.setConnectTimeout(connectTimeoutMs);
connection.setReadTimeout((int)request.timeout.toMillis());
for (Map.Entry<String, String> headerEntry : request.headers.entrySet()) {
connection.setRequestProperty(headerEntry.getKey(), headerEntry.getValue());
}
try {
connection.setDoOutput(true);
try (OutputStream outputStream = connection.getOutputStream()) {
byte[] input = request.body;
outputStream.write(input, 0, input.length);
}
int statusCode = connection.getResponseCode();
InputStream inputStream = (statusCode < HttpURLConnection.HTTP_BAD_REQUEST)
? connection.getInputStream()
: connection.getErrorStream();
try (BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
StringBuilder response = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
response.append(line.trim());
}
return new Response(statusCode, response.toString());
}
} finally {
connection.disconnect();
}
}
public static final class Response {
private final int statusCode;
private final String body;
private Response(int statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
public int statusCode() {
return statusCode;
}
public String body() {
return body;
}
}
public static final class Request {
private final String method;
private final Map<String, String> headers;
private final Duration timeout;
private final URI uri;
private final byte[] body;
private Request(
String method,
Map<String, String> headers,
Duration timeout,
URI uri,
byte[] body
) {
this.method = method;
this.headers = headers;
this.timeout = timeout;
this.uri = uri;
this.body = body;
}
public static Builder newBuilder() {
return new Builder();
}
public static final class Builder {
private String method = "GET";
private Map<String, String> headers = new HashMap<>();
private Duration timeout = Duration.ofSeconds(30);
private URI uri;
private byte[] body = {};
public Builder method(String method) {
this.method = method;
return this;
}
public Builder header(String key, String value) {
headers.put(key, value);
return this;
}
public Builder timeout(Duration duration) {
this.timeout = timeout;
return this;
}
public Builder uri(URI uri) {
this.uri = uri;
return this;
}
public Builder body(byte[] body) {
this.body = body;
return this;
}
public Builder POST(byte[] body) {
method("POST");
body(body);
return this;
}
public Builder PUT(byte[] body) {
method("PUT");
body(body);
return this;
}
public Request build() {
return new Request(
method,
Collections.unmodifiableMap(headers),
timeout,
uri,
body
);
}
}
}
public static Builder newBuilder() {
return new Builder();
}
public static final class Builder {
private Duration connectTimeout = Duration.ofSeconds(30);
public Builder connectTimeout(Duration duration) {
this.connectTimeout = duration;
return this;
}
public Java8HttpClient build() {
return new Java8HttpClient(
connectTimeout
);
}
}
}

View File

@@ -0,0 +1,38 @@
package dev.faststats.bukkit;
import dev.faststats.core.Metrics;
import org.bukkit.plugin.IllegalPluginAccessException;
import org.bukkit.plugin.Plugin;
/**
* Bukkit metrics implementation.
*
* @since 0.1.0
*/
public interface BukkitMetrics extends Metrics {
/**
* Creates a new metrics factory for Bukkit.
*
* @return the metrics factory
* @since 0.1.0
*/
// @Contract(pure = true)
static Factory factory() {
return new BukkitMetricsImpl.Factory();
}
/**
* Registers additional exception handlers on Paper-based implementations.
*
* @throws IllegalPluginAccessException if the plugin is not yet enabled
* @apiNote This method may only be called {@link Plugin#onEnable() onEnable()}.
* @since 0.14.0
*/
@Override
void ready() throws IllegalPluginAccessException;
interface Factory extends Metrics.Factory<Plugin, Factory> {
@Override
BukkitMetrics create(Plugin object) throws IllegalStateException;
}
}

View File

@@ -0,0 +1,132 @@
package dev.faststats.bukkit;
import com.google.gson.JsonObject;
import com.janboerman.invsee.utils.Compat;
import dev.faststats.core.SimpleMetrics;
import org.bukkit.Server;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.plugin.Plugin;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.logging.Level;
final class BukkitMetricsImpl extends SimpleMetrics implements BukkitMetrics {
private final Plugin plugin;
private final String pluginVersion;
private final String minecraftVersion;
private final String serverType;
@SuppressWarnings({"deprecation", "Convert2MethodRef"})
private BukkitMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException {
super(factory, config);
this.plugin = plugin;
final Server server = plugin.getServer();
this.pluginVersion = tryOrEmpty(() -> plugin.getPluginMeta().getVersion())
.orElseGet(() -> plugin.getDescription().getVersion());
this.minecraftVersion = Compat.optionalOr(tryOrEmpty(() -> server.getMinecraftVersion()),
() -> tryOrEmpty(() -> server.getBukkitVersion().split("-", 2)[0]))
.orElseGet(() -> server.getVersion().split("\\(MC: |\\)", 3)[1]);
this.serverType = server.getName();
startSubmitting();
}
Plugin plugin() {
return plugin;
}
private boolean checkOnlineMode() {
final Server server = plugin.getServer();
return Compat.optionalOr(
tryOrEmpty(() -> server.getServerConfig().isProxyOnlineMode()),
() -> tryOrEmpty(this::isProxyOnlineMode)
).orElseGet(server::getOnlineMode);
}
@SuppressWarnings("removal")
private boolean isProxyOnlineMode() {
final Server server = plugin.getServer();
final ConfigurationSection proxies = server.spigot().getPaperConfig().getConfigurationSection("proxies");
if (proxies == null) return false;
if (proxies.getBoolean("velocity.enabled") && proxies.getBoolean("velocity.online-mode")) return true;
final ConfigurationSection settings = server.spigot().getSpigotConfig().getConfigurationSection("settings");
if (settings == null) return false;
return settings.getBoolean("bungeecord") && proxies.getBoolean("bungee-cord.online-mode");
}
@Override
protected void appendDefaultData(final JsonObject metrics) {
metrics.addProperty("minecraft_version", minecraftVersion);
metrics.addProperty("online_mode", checkOnlineMode());
metrics.addProperty("player_count", getPlayerCount());
metrics.addProperty("plugin_version", pluginVersion);
metrics.addProperty("server_type", serverType);
}
private int getPlayerCount() {
try {
return plugin.getServer().getOnlinePlayers().size();
} catch (final Throwable t) {
error("Failed to get player count", t);
return 0;
}
}
@Override
protected void printError(final String message, /*@Nullable*/ final Throwable throwable) {
plugin.getLogger().log(Level.SEVERE, message, throwable);
}
@Override
protected void printInfo(final String message) {
plugin.getLogger().info(message);
}
@Override
protected void printWarning(final String message) {
plugin.getLogger().warning(message);
}
@Override
public void ready() {
if (getErrorTracker().isPresent()) try {
Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent");
plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(this), plugin);
} catch (final ClassNotFoundException ignored) {
}
}
private <T> Optional<T> tryOrEmpty(final Supplier<T> supplier) {
try {
return Optional.of(supplier.get());
} catch (final NoSuchMethodError | Exception e) {
return Optional.empty();
}
}
static final class Factory extends SimpleMetrics.Factory<Plugin, BukkitMetrics.Factory> implements BukkitMetrics.Factory {
@Override
public BukkitMetrics create(final Plugin plugin) throws IllegalStateException {
final Path dataFolder = getPluginsFolder(plugin).resolve("faststats");
final Path config = dataFolder.resolve("config.properties");
return new BukkitMetricsImpl(this, plugin, config);
}
private static Path getPluginsFolder(final Plugin plugin) {
try {
return plugin.getServer().getPluginsFolder().toPath();
} catch (final NoSuchMethodError e) {
return plugin.getDataFolder().getParentFile().toPath();
}
}
}
}

View File

@@ -0,0 +1,25 @@
package dev.faststats.bukkit;
import com.destroystokyo.paper.event.server.ServerExceptionEvent;
import com.destroystokyo.paper.exception.ServerPluginException;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
final class PaperEventListener implements Listener {
private final BukkitMetricsImpl metrics;
PaperEventListener(BukkitMetricsImpl metrics) {
this.metrics = metrics;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onServerException(final ServerExceptionEvent event) {
if (!(event.getException() instanceof ServerPluginException)) return;
ServerPluginException exception = (ServerPluginException) event.getException();
if (!exception.getResponsiblePlugin().equals(metrics.plugin())) return;
final Throwable report = exception.getCause() != null ? exception.getCause() : exception;
metrics.getErrorTracker().ifPresent(tracker -> tracker.trackError(report, false));
}
}

View File

@@ -0,0 +1,222 @@
package dev.faststats.core;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
final class ErrorHelper {
private static final int MESSAGE_LENGTH = Math.min(1000, Integer.getInteger("faststats.message-length", 500));
private static final int STACK_TRACE_LENGTH = Math.min(500, Integer.getInteger("faststats.stack-trace-length", 300));
private static final int STACK_TRACE_LIMIT = Math.min(50, Integer.getInteger("faststats.stack-trace-limit", 15));
public static JsonObject compile(final Throwable error, /*@Nullable*/ final List<String> suppress, final boolean handled) {
final JsonObject report = new JsonObject();
final String message = getAnonymizedMessage(error);
final JsonArray stacktrace = new JsonArray();
final String header = message != null
? error.getClass().getName() + ": " + message
: error.getClass().getName();
stacktrace.add(header);
final StackTraceElement[] elements = error.getStackTrace();
final List<String> stack = collapseStackTrace(elements);
final ArrayList<String> list = new ArrayList<>(stack);
if (suppress != null) list.removeAll(suppress);
final int traces = Math.min(list.size(), STACK_TRACE_LIMIT);
populateTraces(traces, list, elements, stacktrace);
appendCauseChain(error.getCause(), stack, suppress, stacktrace);
report.addProperty("error", error.getClass().getName());
if (message != null) report.addProperty("message", message);
report.add("stack", stacktrace);
report.addProperty("handled", handled);
return report;
}
private static void appendCauseChain(/*@Nullable*/ Throwable cause, final List<String> parentStack,
/*@Nullable*/ final List<String> suppress, final JsonArray stacktrace) {
final ArrayList<String> toSuppress = new ArrayList<>(parentStack);
if (suppress != null) toSuppress.addAll(suppress);
final Set<Throwable> visited = Collections.<Throwable>newSetFromMap(new IdentityHashMap<>());
while (cause != null && visited.add(cause)) {
final String causeMessage = getAnonymizedMessage(cause);
final String header = causeMessage != null
? "Caused by: " + cause.getClass().getName() + ": " + causeMessage
: "Caused by: " + cause.getClass().getName();
stacktrace.add(header);
final StackTraceElement[] causeElements = cause.getStackTrace();
final List<String> causeStack = collapseStackTrace(causeElements);
final ArrayList<String> causeList = new ArrayList<>(causeStack);
causeList.removeAll(toSuppress);
final int causeTraces = Math.min(causeList.size(), STACK_TRACE_LIMIT);
populateTraces(causeTraces, causeList, causeElements, stacktrace);
cause = cause.getCause();
}
}
private static void populateTraces(final int traces, final List<String> list, final StackTraceElement[] elements, final JsonArray stacktrace) {
for (int i = 0; i < traces; i++) {
final String string = list.get(i);
if (string.length() <= STACK_TRACE_LENGTH) stacktrace.add(" at " + string);
else stacktrace.add(" at " + string.substring(0, STACK_TRACE_LENGTH) + "...");
}
if (traces > 0 && traces < list.size()) {
stacktrace.add(" ... " + (list.size() - traces) + " more");
} else {
final int i = elements.length - list.size();
if (i > 0) stacktrace.add(" ... " + i + " more");
}
}
private static List<String> collapseStackTrace(final StackTraceElement[] trace) {
final List<String> lines = Arrays.stream(trace)
.map(StackTraceElement::toString)
.collect(Collectors.toList());
return collapseRepeatingPattern(lines);
}
private static List<String> collapseRepeatingPattern(final List<String> lines) {
final List<String> deduplicated = collapseConsecutiveDuplicates(lines);
final int n = deduplicated.size();
for (int cycleLen = 1; cycleLen <= n / 2; cycleLen++) {
boolean isPattern = true;
int repetitions = 0;
for (int i = 0; i < n; i++) {
if (!deduplicated.get(i).equals(deduplicated.get(i % cycleLen))) {
isPattern = false;
break;
}
if (i > 0 && i % cycleLen == 0) repetitions++;
}
if (isPattern && repetitions >= 2) {
return deduplicated.subList(0, cycleLen);
}
}
return deduplicated;
}
private static List<String> collapseConsecutiveDuplicates(final List<String> lines) {
if (lines.isEmpty()) return lines;
final ArrayList<String> result = new ArrayList<String>();
String previous = null;
for (final String line : lines) {
if (line.equals(previous)) continue;
result.add(line);
previous = line;
}
return result;
}
public static boolean isSameLoader(final ClassLoader loader, final Throwable error) {
return isSameLoader(loader, error, Collections.newSetFromMap(new IdentityHashMap<>()));
}
private static boolean isSameLoader(final ClassLoader loader, /*@Nullable*/ final Throwable error, final Set<Throwable> visited) {
if (error == null || !visited.add(error)) return false;
final StackTraceElement[] stackTrace = error.getStackTrace();
if (stackTrace == null || stackTrace.length == 0)
return isSameLoader(loader, error.getCause(), visited);
final int firstNonLibraryIndex = findFirstNonLibraryFrameIndex(stackTrace);
if (firstNonLibraryIndex == -1) return isSameLoader(loader, error.getCause(), visited);
final int framesToCheck = Math.min(5, stackTrace.length - firstNonLibraryIndex);
for (int i = 0; i < framesToCheck; i++) {
final StackTraceElement frame = stackTrace[firstNonLibraryIndex + i];
if (isLibraryClass(frame.getClassName())) continue;
if (!isFromLoader(frame, loader)) return isSameLoader(loader, error.getCause(), visited);
}
return true;
}
private static int findFirstNonLibraryFrameIndex(final StackTraceElement[] stackTrace) {
for (int i = 0; i < stackTrace.length; i++) {
if (!isLibraryClass(stackTrace[i].getClassName())) return i;
}
return -1;
}
private static boolean isLibraryClass(final String className) {
return className.startsWith("java.")
|| className.startsWith("javax.")
|| className.startsWith("sun.")
|| className.startsWith("com.sun.")
|| className.startsWith("jdk.");
}
private static boolean isFromLoader(final StackTraceElement frame, final ClassLoader loader) {
try {
final Class<?> clazz = Class.forName(frame.getClassName(), false, loader);
return isSameClassLoader(clazz.getClassLoader(), loader);
} catch (final Throwable t) {
return false;
}
}
private static boolean isSameClassLoader(final ClassLoader classLoader, final ClassLoader loader) {
if (classLoader == loader) return true;
ClassLoader current = classLoader;
while (current != null && current != loader) {
current = current.getParent();
}
return loader == current;
}
private static final Pattern IPV4_PATTERN = Pattern.compile(
"\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\b");
private static final Pattern IPV6_PATTERN = Pattern.compile(
"(?i)\\b([0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\\b|" + // Full form
"(?i)\\b([0-9a-f]{1,4}:){1,7}:\\b|" + // Trailing ::
"(?i)\\b([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}\\b|" + // :: in middle (1 group after)
"(?i)\\b([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}\\b|" + // :: in middle (2 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}\\b|" + // :: in middle (3 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\\b|" + // :: in middle (4 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\\b|" + // :: in middle (5 groups after)
"(?i)\\b[0-9a-f]{1,4}:(:[0-9a-f]{1,4}){1,6}\\b|" + // :: in middle (6 groups after)
"(?i)\\b:(:[0-9a-f]{1,4}){1,7}\\b|" + // Leading ::
"(?i)\\b::([0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4}\\b|" + // :: at start
"(?i)\\b::\\b"); // Just ::
private static final Pattern USER_HOME_PATH_PATTERN = Pattern.compile(
"(/home/)[^/\\s]+" + // Linux: /home/username
"|(/Users/)[^/\\s]+" + // macOS: /Users/username
"|((?i)[A-Z]:\\\\Users\\\\)[^\\\\\\s]+"); // Windows: A-Z:\\Users\\username
private static String anonymize(String message) {
message = IPV4_PATTERN.matcher(message).replaceAll("[IP hidden]");
message = IPV6_PATTERN.matcher(message).replaceAll("[IP hidden]");
message = USER_HOME_PATH_PATTERN.matcher(message).replaceAll("$1$2$3[username hidden]");
final String username = System.getProperty("user.name");
if (username != null) message = message.replace(username, "[username hidden]");
return message;
}
private static /*@Nullable*/ String getAnonymizedMessage(final Throwable error) {
final String message = error.getMessage();
if (message == null) return null;
final String truncated = message.length() > MESSAGE_LENGTH
? message.substring(0, MESSAGE_LENGTH) + "..."
: message;
return anonymize(truncated);
}
}

View File

@@ -0,0 +1,240 @@
package dev.faststats.core;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.regex.Pattern;
/**
* An error tracker.
*
* @since 0.10.0
*/
public interface ErrorTracker {
/**
* Create and attach a new context-aware error tracker.
* <p>
* This tracker will automatically track errors that occur in the same class loader as the tracker itself.
* <p>
* You can still manually track errors using {@code #trackError}.
*
* @return the error tracker
* @see #contextUnaware()
* @see #trackError(String, boolean)
* @see #trackError(Throwable, boolean)
* @since 0.10.0
*/
// @Contract(value = " -> new")
static ErrorTracker contextAware() {
final SimpleErrorTracker tracker = new SimpleErrorTracker();
tracker.attachErrorContext(ErrorTracker.class.getClassLoader());
return tracker;
}
/**
* Create a new context-unaware error tracker.
* <p>
* This tracker will not automatically track any errors.
* <p>
* You have to manually track errors using {@code #trackError}.
*
* @return the error tracker
* @see #contextAware()
* @see #trackError(String)
* @see #trackError(Throwable)
* @since 0.10.0
*/
// @Contract(value = " -> new")
static ErrorTracker contextUnaware() {
return new SimpleErrorTracker();
}
/**
* Tracks a handled error.
*
* @param message the error message
* @see #trackError(Throwable)
* @see #trackError(String, boolean)
* @since 0.10.0
*/
// @Contract(mutates = "this")
void trackError(String message);
/**
* Tracks a handled error.
*
* @param error the error
* @see #trackError(Throwable, boolean)
* @since 0.10.0
*/
// @Contract(mutates = "this")
void trackError(Throwable error);
/**
* Tracks an error.
* <p>
* A {@code handled=true} error is expected and properly handled.
*
* @param message the error message
* @param handled whether the error was handled
* @see #trackError(Throwable, boolean)
* @since 0.20.0
*/
// @Contract(mutates = "this")
void trackError(String message, boolean handled);
/**
* Tracks an error.
* <p>
* A {@code handled=true} error is expected and properly handled.
*
* @param error the error
* @param handled whether the error was handled
* @since 0.20.0
*/
// @Contract(mutates = "this")
void trackError(Throwable error, boolean handled);
/**
* Adds an error type that will not be reported to FastStats.
* <p>
* Matching is done exactly. If, for example {@link LinkageError} was ignored,
* {@link NoClassDefFoundError} would still be reported, even though it extends {@link LinkageError}
*
* @param type the error type
* @return the error tracker
* @since 0.21.0
*/
// @Contract(value = "_ -> this", mutates = "this")
ErrorTracker ignoreErrorType(Class<? extends Throwable> type);
/**
* Adds a pattern that will be matched against all error messages.
* <p>
* If an error's message matches the given pattern, it will not be reported to FastStats.
* <pre>{@code
* // Exact match
* tracker.ignoreError(Pattern.compile("No space left on device"));
*
* // Regex match
* tracker.ignoreError(Pattern.compile("No serializer for: class .*"));
* }</pre>
*
* @param pattern the regex pattern to match against error messages
* @return the error tracker
* @since 0.21.0
*/
// @Contract(value = "_ -> this", mutates = "this")
ErrorTracker ignoreError(Pattern pattern);
/**
* Adds a pattern that will be matched against all error messages.
* <p>
* If an error's message matches the given pattern, it will not be reported to FastStats.
*
* @param pattern the regex pattern string to match against error messages
* @return the error tracker
* @see #ignoreError(Pattern)
* @since 0.21.0
*/
// @Contract(value = "_ -> this", mutates = "this")
default ErrorTracker ignoreError(/*@RegExp*/ final String pattern) {
return ignoreError(Pattern.compile(pattern));
}
/**
* Adds an error type combined with a message pattern that will not be reported to FastStats.
* <p>
* An error is ignored only if its class matches the given type exactly and its message matches the given pattern.
* <pre>{@code
* tracker.ignoreError(IOException.class, Pattern.compile("No space left on device"));
* }</pre>
*
* @param type the error type
* @param pattern the regex pattern to match against error messages
* @return the error tracker
* @since 0.21.0
*/
// @Contract(value = "_, _ -> this", mutates = "this")
ErrorTracker ignoreError(Class<? extends Throwable> type, Pattern pattern);
/**
* Adds an error type combined with a message pattern that will not be reported to FastStats.
* <p>
* An error is ignored only if its class matches the given type exactly and its message matches the given pattern.
*
* @param type the error type
* @param pattern the regex pattern string to match against error messages
* @return the error tracker
* @see #ignoreError(Class, Pattern)
* @since 0.21.0
*/
// @Contract(value = "_, _ -> this", mutates = "this")
default ErrorTracker ignoreError(final Class<? extends Throwable> type, /*@RegExp*/ final String pattern) {
return ignoreError(type, Pattern.compile(pattern));
}
/**
* Attaches an error context to the tracker.
* <p>
* If the class loader is {@code null}, the tracker will track all errors.
*
* @param loader the class loader
* @throws IllegalStateException if the error context is already attached
* @since 0.10.0
*/
void attachErrorContext(/*@Nullable*/ ClassLoader loader) throws IllegalStateException;
/**
* Detaches the error context from the tracker.
* <p>
* This restores the original uncaught exception handler that was in place before
* {@link #attachErrorContext(ClassLoader)} was called.
* <p>
* This should be called during shutdown to prevent {@link BootstrapMethodError}
* when the provider's JAR file is closed.
*
* @since 0.13.0
*/
void detachErrorContext();
/**
* Returns whether an error context is attached.
*
* @return whether an error context is attached
* @since 0.13.0
*/
boolean isContextAttached();
/**
* Sets the error event handler which will be called when an error is tracked automatically.
* <p>
* The purpose of this handler is to allow custom error handling like logging.
*
* @param errorEvent the error event handler
* @since 0.11.0
*/
// @Contract(mutates = "this")
void setContextErrorHandler(/*@Nullable*/ BiConsumer</*@Nullable*/ ClassLoader, Throwable> errorEvent);
/**
* Returns the error event handler which will be called when an error is tracked automatically.
*
* @return the error event handler
* @since 0.11.0
*/
// @Contract(pure = true)
Optional<BiConsumer</*@Nullable*/ ClassLoader, Throwable>> getContextErrorHandler();
/**
* Checks if the error occurred in the same class loader as the provided loader.
*
* @param loader the class loader
* @param error the error
* @return whether the error occurred in the same class loader
* @since 0.14.0
*/
// @Contract(pure = true)
static boolean isSameLoader(final ClassLoader loader, final Throwable error) {
return ErrorHelper.isSameLoader(loader, error);
}
}

View File

@@ -0,0 +1,218 @@
package dev.faststats.core;
import dev.faststats.core.data.Metric;
import java.net.URI;
import java.util.Optional;
import java.util.UUID;
/**
* Metrics interface.
*
* @since 0.1.0
*/
public interface Metrics {
/**
* Get the token used to authenticate with the metrics server and identify the project.
*
* @return the metrics token
* @since 0.1.0
*/
@Token
// @Contract(pure = true)
String getToken();
/**
* Get the error tracker for this metrics instance.
*
* @return the error tracker
* @since 0.10.0
*/
// @Contract(pure = true)
Optional<ErrorTracker> getErrorTracker();
/**
* Get the metrics configuration.
*
* @return the metrics configuration
* @since 0.1.0
*/
// @Contract(pure = true)
Config getConfig();
/**
* Performs additional post-startup tasks.
* <p>
* This method may only be called when the application startup is complete.
* <p>
* <i>No-op in most implementations.</i>
*
* @since 0.14.0
*/
default void ready() {
}
/**
* Safely shuts down the metrics submission.
* <p>
* This method should be called when the application is shutting down.
*
* @since 0.1.0
*/
// @Contract(mutates = "this")
void shutdown();
/**
* A metrics factory.
*
* @since 0.1.0
*/
interface Factory<T, F extends Factory<T, F>> {
/**
* Adds a metric to the metrics submission.
* <p>
* If {@link Config#additionalMetrics()} is disabled, the metric will not be submitted.
*
* @param metric the metric to add
* @return the metrics factory
* @throws IllegalArgumentException if the metric is already added
* @since 0.16.0
*/
// @Contract(mutates = "this")
F addMetric(Metric<?> metric) throws IllegalArgumentException;
/**
* Sets the flush callback for this metrics instance.
* <p>
* This callback will be invoked when the metrics have been submitted to, and accepted by, the metrics server.
*
* @param flush the flush callback
* @return the metrics factory
* @since 0.15.0
*/
// @Contract(mutates = "this")
F onFlush(Runnable flush);
/**
* Sets the error tracker for this metrics instance.
* <p>
* If {@link Config#errorTracking()} is disabled, no errors will be submitted.
*
* @param tracker the error tracker
* @return the metrics factory
* @since 0.10.0
*/
// @Contract(mutates = "this")
F errorTracker(ErrorTracker tracker);
/**
* Enables or disabled debug mode for this metrics instance.
* <p>
* If {@link Config#debug()} is enabled, debug logging will be enabled for all metrics instances,
* including this one, regardless of this setting.
* <p>
* This is only meant for development and testing and should not be enabled in production.
*
* @param enabled whether debug mode is enabled
* @return the metrics factory
* @since 0.1.0
*/
// @Contract(mutates = "this")
F debug(boolean enabled);
/**
* Sets the token used to authenticate with the metrics server and identify the project.
* <p>
* This token can be found in the settings of your project under <b>"Your API Token"</b>.
*
* @param token the metrics token
* @return the metrics factory
* @throws IllegalArgumentException if the token does not match the {@link Token#PATTERN}
* @since 0.1.0
*/
// @Contract(mutates = "this")
F token(@Token String token) throws IllegalArgumentException;
/**
* Sets the metrics server URL.
* <p>
* This is only required for self-hosted metrics servers.
*
* @param url the metrics server URL
* @return the metrics factory
* @since 0.1.0
*/
// @Contract(mutates = "this")
F url(URI url);
/**
* Creates a new metrics instance.
* <p>
* Metrics submission will start automatically.
*
* @param object a required object as defined by the implementation
* @return the metrics instance
* @throws IllegalStateException if the token is not specified
* @see #token(String)
* @since 0.1.0
*/
// @Async.Schedule
// @Contract(value = "_ -> new", mutates = "io")
Metrics create(T object) throws IllegalStateException;
}
/**
* A representation of the metrics configuration.
*
* @since 0.1.0
*/
interface Config {
/**
* The server id.
*
* @return the server id
* @since 0.1.0
*/
// @Contract(pure = true)
UUID serverId();
/**
* Whether metrics submission is enabled.
* <p>
* <b>Bypassing this setting may get your project banned from FastStats.</b><br>
* <b>Users have to be able to opt out from metrics submission.</b>
*
* @return {@code true} if metrics submission is enabled, {@code false} otherwise
* @since 0.1.0
*/
// @Contract(pure = true)
boolean enabled();
/**
* Whether error tracking is enabled across all metrics instances.
*
* @return {@code true} if error tracking is enabled, {@code false} otherwise
* @since 0.11.0
*/
// @Contract(pure = true)
boolean errorTracking();
/**
* Whether additional metrics are enabled across all metrics instances.
*
* @return {@code true} if additional metrics are enabled, {@code false} otherwise
* @since 0.11.0
*/
// @Contract(pure = true)
boolean additionalMetrics();
/**
* Whether debug logging is enabled across all metrics instances.
*
* @return {@code true} if debug logging is enabled, {@code false} otherwise
* @since 0.1.0
*/
// @Contract(pure = true)
boolean debug();
}
}

View File

@@ -0,0 +1,187 @@
package dev.faststats.core;
import com.google.gson.JsonObject;
import java.nio.charset.StandardCharsets;
/**
* Implementation of the MurmurHash3 128-bit hash algorithm.
* <p>
* MurmurHash is a non-cryptographic hash function suitable for general hash-based lookup.
* It provides excellent distribution and performance while minimizing collisions.
* </p>
* <p>
* This implementation follows the MurmurHash3_x64_128 variant as described at:
* <a href="https://en.wikipedia.org/wiki/MurmurHash">https://en.wikipedia.org/wiki/MurmurHash</a>
* </p>
* <p>
* Original algorithm by Austin Appleby. The name comes from the two elementary operations
* it uses: multiply (MU) and rotate (R).
* </p>
*/
final class MurmurHash3 {
public static String hash(final JsonObject object) {
final long[] hash = MurmurHash3.hash(object.toString());
return Long.toHexString(hash[0]) + Long.toHexString(hash[1]);
}
/**
* Computes the 128-bit MurmurHash3 hash of the input string.
* <p>
* The string is encoded to UTF-8 bytes before hashing. The result is returned
* as an array of two long values (64 bits each), combined they form a 128-bit hash.
* </p>
*
* @param data the input string to hash
* @return a 2-element array containing the lower 64 bits at index 0 and upper 64 bits at index 1
* @see <a href="https://en.wikipedia.org/wiki/MurmurHash">MurmurHash on Wikipedia</a>
*/
// @Contract(value = "_ -> new", pure = true)
private static long[] hash(final String data) {
final byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
long h1 = 0L;
long h2 = 0L;
final long c1 = 0x87c37b91114253d5L;
final long c2 = 0x4cf5ad432745937fL;
final int length = bytes.length;
final int blocks = length / 16;
// Process 128-bit blocks
for (int i = 0; i < blocks; i++) {
int k1 = getInt(bytes, i * 16);
int k2 = getInt(bytes, i * 16 + 4);
final int k3 = getInt(bytes, i * 16 + 8);
final int k4 = getInt(bytes, i * 16 + 12);
k1 *= (int) c1;
k1 = Integer.rotateLeft(k1, 31);
k1 *= (int) c2;
h1 ^= k1;
h1 = Long.rotateLeft(h1, 27);
h1 += h2;
h1 = h1 * 5 + 0x52dce729;
k2 *= (int) c2;
k2 = Integer.rotateLeft(k2, 33);
k2 *= (int) c1;
h2 ^= k2;
h2 = Long.rotateLeft(h2, 31);
h2 += h1;
h2 = h2 * 5 + 0x38495ab5;
}
// Tail
int k1 = 0;
int k2 = 0;
int k3 = 0;
int k4 = 0;
final int tail = blocks * 16;
switch (length & 15) {
case 15:
k4 ^= (bytes[tail + 14] & 0xff) << 16;
case 14:
k4 ^= (bytes[tail + 13] & 0xff) << 8;
case 13:
k4 ^= (bytes[tail + 12] & 0xff);
k4 *= (int) c2;
k4 = Integer.rotateLeft(k4, 33);
k4 *= (int) c1;
h2 ^= k4;
case 12:
k3 ^= (bytes[tail + 11] & 0xff) << 24;
case 11:
k3 ^= (bytes[tail + 10] & 0xff) << 16;
case 10:
k3 ^= (bytes[tail + 9] & 0xff) << 8;
case 9:
k3 ^= (bytes[tail + 8] & 0xff);
k3 *= (int) c1;
k3 = Integer.rotateLeft(k3, 31);
k3 *= (int) c2;
h1 ^= k3;
case 8:
k2 ^= (bytes[tail + 7] & 0xff) << 24;
case 7:
k2 ^= (bytes[tail + 6] & 0xff) << 16;
case 6:
k2 ^= (bytes[tail + 5] & 0xff) << 8;
case 5:
k2 ^= (bytes[tail + 4] & 0xff);
k2 *= (int) c2;
k2 = Integer.rotateLeft(k2, 33);
k2 *= (int) c1;
h2 ^= k2;
case 4:
k1 ^= (bytes[tail + 3] & 0xff) << 24;
case 3:
k1 ^= (bytes[tail + 2] & 0xff) << 16;
case 2:
k1 ^= (bytes[tail + 1] & 0xff) << 8;
case 1:
k1 ^= (bytes[tail] & 0xff);
k1 *= (int) c1;
k1 = Integer.rotateLeft(k1, 31);
k1 *= (int) c2;
h1 ^= k1;
}
// Finalization
h1 ^= length;
h2 ^= length;
h1 += h2;
h2 += h1;
h1 = fmix64(h1);
h2 = fmix64(h2);
h1 += h2;
h2 += h1;
return new long[]{h1, h2};
}
/**
* Finalization mix function to avalanche the bits in the hash.
* <p>
* This function improves the distribution of the hash by XORing and multiplying
* with carefully chosen constants, ensuring that similar inputs produce very
* different outputs (avalanche effect).
* </p>
*
* @param k the 64-bit value to mix
* @return the mixed 64-bit value
* @see <a href="https://en.wikipedia.org/wiki/MurmurHash#Algorithm">MurmurHash Algorithm on Wikipedia</a>
*/
// @Contract(pure = true)
private static long fmix64(long k) {
k ^= k >>> 33;
k *= 0xff51afd7ed558ccdL;
k ^= k >>> 33;
k *= 0xc4ceb9fe1a85ec53L;
k ^= k >>> 33;
return k;
}
/**
* Reads a 32-bit little-endian integer from the byte array at the specified offset.
* <p>
* This helper method extracts four consecutive bytes and combines them into a
* single integer using little-endian byte order.
* </p>
*
* @param bytes the byte array to read from
* @param offset the starting index in the byte array (must have at least 4 bytes from offset)
* @return the 32-bit integer value read in little-endian order
*/
// @Contract(pure = true)
private static int getInt(final byte[] bytes, final int offset) {
return (bytes[offset] & 0xff) |
((bytes[offset + 1] & 0xff) << 8) |
((bytes[offset + 2] & 0xff) << 16) |
((bytes[offset + 3] & 0xff) << 24);
}
}

View File

@@ -0,0 +1,164 @@
package dev.faststats.core;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.lang.Thread.UncaughtExceptionHandler;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.BiConsumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class SimpleErrorTracker implements ErrorTracker {
private final Map<String, Integer> collected = new ConcurrentHashMap<>();
private final Map<String, JsonObject> reports = new ConcurrentHashMap<>();
private final Map<Class<? extends Throwable>, Set<Pattern>> ignoredTypedPatterns = new ConcurrentHashMap<>();
private final Set<Class<? extends Throwable>> ignoredTypes = new CopyOnWriteArraySet<>();
private final Set<Pattern> ignoredPatterns = new CopyOnWriteArraySet<>();
private volatile /*@Nullable*/ BiConsumer</*@Nullable*/ ClassLoader, Throwable> errorEvent = null;
private volatile /*@Nullable*/ UncaughtExceptionHandler originalHandler = null;
@Override
public void trackError(final String message) {
trackError(message, true);
}
@Override
public void trackError(final Throwable error) {
trackError(error, true);
}
@Override
public void trackError(final String message, final boolean handled) {
trackError(new RuntimeException(message), handled);
}
@Override
public void trackError(final Throwable error, final boolean handled) {
try {
if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return;
final JsonObject compiled = ErrorHelper.compile(error, null, handled);
final String hashed = MurmurHash3.hash(compiled);
if (collected.compute(hashed, (k, v) -> {
return v == null ? 1 : v + 1;
}) > 1) return;
reports.put(hashed, compiled);
} catch (final NoClassDefFoundError ignored) {
}
}
private boolean isIgnored(/*@Nullable*/ final Throwable error, final Set<Throwable> visited) {
if (error == null || !visited.add(error)) return false;
if (ignoredTypes.contains(error.getClass())) return true;
final String message = error.getMessage() != null ? error.getMessage() : "";
if (ignoredPatterns.stream().map(pattern -> pattern.matcher(message)).anyMatch(Matcher::find)) return true;
final Set<Pattern> patterns = ignoredTypedPatterns.get(error.getClass());
if (patterns != null && patterns.stream().map(pattern -> pattern.matcher(message)).anyMatch(Matcher::find))
return true;
return isIgnored(error.getCause(), visited);
}
@Override
public ErrorTracker ignoreErrorType(final Class<? extends Throwable> type) {
ignoredTypes.add(type);
return this;
}
@Override
public ErrorTracker ignoreError(final Pattern pattern) {
ignoredPatterns.add(pattern);
return this;
}
@Override
public ErrorTracker ignoreError(final Class<? extends Throwable> type, final Pattern pattern) {
ignoredTypedPatterns.computeIfAbsent(type, k -> new CopyOnWriteArraySet<>()).add(pattern);
return this;
}
public JsonArray getData(final String buildId) {
final JsonArray report = new JsonArray(reports.size());
reports.forEach((hash, object) -> {
final JsonObject copy = object.deepCopy();
copy.addProperty("hash", hash);
copy.addProperty("buildId", buildId);
final Integer count = collected.getOrDefault(hash, 1);
if (count > 1) copy.addProperty("count", count);
report.add(copy);
});
collected.forEach((hash, count) -> {
if (count <= 0 || reports.containsKey(hash)) return;
final JsonObject entry = new JsonObject();
entry.addProperty("hash", hash);
if (count > 1) entry.addProperty("count", count);
report.add(entry);
});
return report;
}
public void clear() {
collected.replaceAll((k, v) -> 0);
reports.clear();
}
public boolean needsFlushing() {
if (!reports.isEmpty()) return true;
for (final Integer value : collected.values()) {
if (value > 0) return true;
}
return false;
}
@Override
public synchronized void attachErrorContext(/*@Nullable*/ final ClassLoader loader) throws IllegalStateException {
if (originalHandler != null) throw new IllegalStateException("Error context already attached");
originalHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler((thread, error) -> {
final Thread.UncaughtExceptionHandler handler = originalHandler;
if (handler != null) handler.uncaughtException(thread, error);
try {
if (loader != null && !ErrorTracker.isSameLoader(loader, error)) return;
final BiConsumer<ClassLoader, Throwable> event = errorEvent;
if (event != null) event.accept(loader, error);
trackError(error, false);
} catch (final Throwable t) {
trackError(t, false);
}
});
}
@Override
public synchronized void detachErrorContext() {
if (originalHandler == null) return;
Thread.setDefaultUncaughtExceptionHandler(originalHandler);
originalHandler = null;
}
@Override
public synchronized boolean isContextAttached() {
return originalHandler != null;
}
@Override
public synchronized void setContextErrorHandler(/*@Nullable*/ final BiConsumer</*@Nullable*/ ClassLoader, Throwable> errorEvent) {
this.errorEvent = errorEvent;
}
@Override
public synchronized Optional<BiConsumer</*@Nullable*/ ClassLoader, Throwable>> getContextErrorHandler() {
return Optional.ofNullable(errorEvent);
}
}

View File

@@ -0,0 +1,509 @@
package dev.faststats.core;
import com.google.gson.JsonObject;
import com.janboerman.invsee.faststats.FastStats;
import com.janboerman.invsee.faststats.Java8HttpClient;
import com.janboerman.invsee.utils.Compat;
import dev.faststats.core.data.Metric;
import java.io.*;
import java.net.ConnectException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiPredicate;
import java.util.zip.GZIPOutputStream;
import static java.nio.charset.StandardCharsets.UTF_8;
public abstract class SimpleMetrics implements Metrics {
private final Java8HttpClient httpClient = Java8HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
private /*@Nullable*/ ScheduledExecutorService executor = null;
private final Set<Metric<?>> metrics;
private final Config config;
private final @Token String token;
private final /*@Nullable*/ ErrorTracker tracker;
private final /*@Nullable*/ Runnable flush;
private final URI url;
private final boolean debug;
private final String SDK_NAME;
private final String SDK_VERSION;
private final String BUILD_ID;
{
final Properties properties = new Properties();
try (final InputStream stream = getClass().getResourceAsStream("/META-INF/faststats.properties")) {
if (stream != null) properties.load(stream);
} catch (final IOException ignored) {
}
this.SDK_NAME = properties.getProperty("name", FastStats.SDK_NAME);
this.SDK_VERSION = properties.getProperty("version", FastStats.SDK_VERSION);
this.BUILD_ID = properties.getProperty("build-id", "unknown");
}
// @Contract(mutates = "io")
@SuppressWarnings("PatternValidation")
protected SimpleMetrics(final Factory<?, ?> factory, final Config config) throws IllegalStateException {
if (factory.token == null) throw new IllegalStateException("Token must be specified");
this.config = config;
this.metrics = config.additionalMetrics ? Compat.setCopy(factory.metrics) : Compat.emptySet();
this.debug = factory.debug || Boolean.getBoolean("faststats.debug") || config.debug();
this.token = factory.token;
this.tracker = config.errorTracking ? factory.tracker : null;
this.flush = factory.flush;
this.url = factory.url;
}
// @Contract(mutates = "io")
protected SimpleMetrics(final Factory<?, ?> factory, final Path config) throws IllegalStateException {
this(factory, Config.read(config));
}
// @VisibleForTesting
protected SimpleMetrics(
final Config config,
final Set<Metric<?>> metrics,
@Token final String token,
/*@Nullable*/ final ErrorTracker tracker,
/*@Nullable*/ final Runnable flush,
final URI url,
final boolean debug
) {
if (!token.matches(Token.PATTERN)) {
throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'");
}
this.metrics = config.additionalMetrics ? Compat.setCopy(metrics) : Compat.emptySet();
this.config = config;
this.debug = debug;
this.token = token;
this.tracker = tracker;
this.flush = flush;
this.url = url;
}
protected String getOnboardingMessage() {
return "This plugin uses FastStats to collect anonymous usage statistics.\n"
+ "No personal or identifying information is ever collected.\n"
+ "To opt out, set 'enabled=false' in the metrics configuration file.\n"
+ "Learn more at: https://faststats.dev/info\n"
+ "\n"
+ "Since this is your first start with FastStats, metrics submission will not start\n"
+ "until you restart the server to allow you to opt out if you prefer.";
}
protected long getInitialDelay() {
return TimeUnit.SECONDS.toMillis(Long.getLong("faststats.initial-delay", 30));
}
protected long getPeriod() {
return TimeUnit.MINUTES.toMillis(30);
}
// @Async.Schedule
// @MustBeInvokedByOverriders
protected void startSubmitting() {
startSubmitting(getInitialDelay(), getPeriod(), TimeUnit.MILLISECONDS);
}
private void startSubmitting(final long initialDelay, final long period, final TimeUnit unit) {
if (Boolean.getBoolean("faststats.first-run")) {
info("Skipping metrics submission due to first-run flag");
return;
}
if (config.firstRun) {
int separatorLength = 0;
final String[] split = getOnboardingMessage().split("\n");
for (final String s : split) if (s.length() > separatorLength) separatorLength = s.length();
String separator = Compat.stringRepeat("-", separatorLength);
printInfo(separator);
for (final String s : split) printInfo(s);
printInfo(separator);
System.setProperty("faststats.first-run", "true");
if (!config.externallyManaged()) return;
}
final boolean enabled = Boolean.parseBoolean(System.getProperty("faststats.enabled", "true"));
if (!config.enabled() || !enabled) {
warn("Metrics disabled, not starting submission");
return;
}
if (isSubmitting()) {
warn("Metrics already submitting, not starting again");
return;
}
this.executor = Executors.newSingleThreadScheduledExecutor(runnable -> {
final Thread thread = new Thread(runnable, "metrics-submitter");
thread.setDaemon(true);
return thread;
});
info("Starting metrics submission");
executor.scheduleAtFixedRate(this::submit, Math.max(0, initialDelay), Math.max(1000, period), unit);
}
protected boolean isSubmitting() {
return executor != null && !executor.isShutdown();
}
public boolean submit() {
try {
return submitNow();
} catch (final Throwable t) {
error("Failed to submit metrics", t);
return false;
}
}
private boolean submitNow() throws IOException {
final String data = createData().toString();
final byte[] bytes = data.getBytes(UTF_8);
info("Uncompressed data: " + data);
try (final ByteArrayOutputStream byteOutput = new ByteArrayOutputStream();
final GZIPOutputStream output = new GZIPOutputStream(byteOutput)) {
output.write(bytes);
output.finish();
final byte[] compressed = byteOutput.toByteArray();
info("Compressed size: " + compressed.length + " bytes");
final Java8HttpClient.Request request = Java8HttpClient.Request.newBuilder()
.POST(compressed)
.header("Content-Encoding", "gzip")
.header("Content-Type", "application/octet-stream")
.header("Authorization", "Bearer " + getToken())
.header("User-Agent", "FastStats Metrics " + SDK_NAME + "/" + SDK_VERSION)
.timeout(Duration.ofSeconds(3))
.uri(url)
.build();
info("Sending metrics to: " + url);
try {
final Java8HttpClient.Response response = httpClient.send(request);
final int statusCode = response.statusCode();
final String body = response.body();
if (statusCode >= 200 && statusCode < 300) {
info("Metrics submitted with status code: " + statusCode + " (" + body + ")");
getErrorTracker().map(SimpleErrorTracker.class::cast).ifPresent(SimpleErrorTracker::clear);
if (flush != null) flush.run();
return true;
} else if (statusCode >= 300 && statusCode < 400) {
warn("Received redirect response from metrics server: " + statusCode + " (" + body + ")");
} else if (statusCode >= 400 && statusCode < 500) {
error("Submitted invalid request to metrics server: " + statusCode + " (" + body + ")", null);
} else if (statusCode >= 500 && statusCode < 600) {
error("Received server error response from metrics server: " + statusCode + " (" + body + ")", null);
} else {
warn("Received unexpected response from metrics server: " + statusCode + " (" + body + ")");
}
} catch (final ConnectException t) {
error("Failed to connect to metrics server: " + url, null);
} catch (final IOException t) {
error("Metrics submission timed out after 3 seconds: " + url, null);
} catch (final Exception e) {
error("Failed to submit metrics", e);
}
return false;
}
}
private final String javaVendor = System.getProperty("java.vendor");
private final String javaVersion = System.getProperty("java.version");
private final String osArch = System.getProperty("os.arch");
private final String osName = System.getProperty("os.name");
private final String osVersion = System.getProperty("os.version");
private final int coreCount = Runtime.getRuntime().availableProcessors();
protected JsonObject createData() {
final JsonObject data = new JsonObject();
final JsonObject metrics = new JsonObject();
metrics.addProperty("core_count", coreCount);
metrics.addProperty("java_vendor", javaVendor);
metrics.addProperty("java_version", javaVersion);
metrics.addProperty("os_arch", osArch);
metrics.addProperty("os_name", osName);
metrics.addProperty("os_version", osVersion);
try {
appendDefaultData(metrics);
} catch (final Throwable t) {
error("Failed to append default data", t);
getErrorTracker().ifPresent(tracker -> tracker.trackError(t));
}
this.metrics.forEach(metric -> {
try {
metric.getData().ifPresent(element -> metrics.add(metric.getId(), element));
} catch (final Throwable t) {
error("Failed to build metric data: " + metric.getId(), t);
getErrorTracker().ifPresent(tracker -> tracker.trackError(t));
}
});
data.addProperty("identifier", config.serverId().toString());
data.add("data", metrics);
getErrorTracker().map(SimpleErrorTracker.class::cast)
.map(tracker -> tracker.getData(BUILD_ID))
.filter(errors -> !errors.isEmpty())
.ifPresent(errors -> data.add("errors", errors));
return data;
}
@Override
public @Token String getToken() {
return token;
}
@Override
public Optional<ErrorTracker> getErrorTracker() {
return Optional.ofNullable(tracker);
}
@Override
public Config getConfig() {
return config;
}
// @Contract(mutates = "param1")
protected abstract void appendDefaultData(JsonObject metrics);
protected void error(final String message, /*@Nullable*/ final Throwable throwable) {
if (debug) printError("[" + getClass().getName() + "]: " + message, throwable);
}
protected void warn(final String message) {
if (debug) printWarning("[" + getClass().getName() + "]: " + message);
}
protected void info(final String message) {
if (debug) printInfo("[" + getClass().getName() + "]: " + message);
}
protected abstract void printError(String message, /*(@Nullable*/ Throwable throwable);
protected abstract void printInfo(String message);
protected abstract void printWarning(String message);
@Override
public void shutdown() {
getErrorTracker().ifPresent(ErrorTracker::detachErrorContext);
if (executor != null) try {
info("Shutting down metrics submission");
executor.shutdown();
getErrorTracker().map(SimpleErrorTracker.class::cast)
.filter(SimpleErrorTracker::needsFlushing)
.ifPresent(ignored -> submit());
} catch (final Throwable t) {
error("Failed to submit metrics on shutdown", t);
} finally {
executor = null;
}
}
public abstract static class Factory<T, F extends Metrics.Factory<T, F>> implements Metrics.Factory<T, F> {
private final Set<Metric<?>> metrics = new HashSet<>(0);
private URI url = URI.create("https://metrics.faststats.dev/v1/collect");
private /*@Nullable*/ ErrorTracker tracker;
private /*@Nullable*/ Runnable flush;
private /*@Nullable*/ String token;
private boolean debug = false;
@Override
@SuppressWarnings("unchecked")
public F addMetric(final Metric<?> metric) throws IllegalArgumentException {
if (!metrics.add(metric)) throw new IllegalArgumentException("Metric already added: " + metric.getId());
return (F) this;
}
@Override
@SuppressWarnings("unchecked")
public F onFlush(final Runnable flush) {
this.flush = flush;
return (F) this;
}
@Override
@SuppressWarnings("unchecked")
public F errorTracker(final ErrorTracker tracker) {
this.tracker = tracker;
return (F) this;
}
@Override
@SuppressWarnings("unchecked")
public F debug(final boolean enabled) {
this.debug = enabled;
return (F) this;
}
@Override
@SuppressWarnings("unchecked")
public F token(@Token final String token) throws IllegalArgumentException {
if (!token.matches(Token.PATTERN)) {
throw new IllegalArgumentException("Invalid token '" + token + "', must match '" + Token.PATTERN + "'");
}
this.token = token;
return (F) this;
}
@Override
@SuppressWarnings("unchecked")
public F url(final URI url) {
this.url = url;
return (F) this;
}
}
public static final class Config implements Metrics.Config {
private final UUID serverId;
private final boolean additionalMetrics;
private final boolean debug;
private final boolean enabled;
private final boolean errorTracking;
private final boolean firstRun;
private final boolean externallyManaged;
public Config(
UUID serverId,
boolean additionalMetrics,
boolean debug,
boolean enabled,
boolean errorTracking,
boolean firstRun,
boolean externallyManaged
) {
this.serverId = serverId;
this.additionalMetrics = additionalMetrics;
this.debug = debug;
this.enabled = enabled;
this.errorTracking = errorTracking;
this.firstRun = firstRun;
this.externallyManaged = externallyManaged;
}
public UUID serverId() { return serverId; }
public boolean additionalMetrics() { return additionalMetrics; }
public boolean debug() { return debug; }
public boolean enabled() { return enabled; }
public boolean errorTracking() { return errorTracking; }
public boolean firstRun() { return firstRun; }
public boolean externallyManaged() { return externallyManaged; }
public static final String DEFAULT_COMMENT =
" FastStats (https://faststats.dev) collects anonymous usage statistics for plugin developers.\n"
+ "# This helps developers understand how their projects are used in the real world.\n"
+ "#\n"
+ "# No IP addresses, player data, or personal information is collected.\n"
+ "# The server ID below is randomly generated and can be regenerated at any time.\n"
+ "#\n"
+ "# Enabling metrics has no noticeable performance impact.\n"
+ "# Keeping metrics enabled is recommended, but you can opt out by setting\n"
+ "# 'enabled=false' in plugins/faststats/config.properties.\n"
+ "#\n"
+ "# If you suspect a plugin is collecting personal data or bypassing the \"enabled\" option,\n"
+ "# please report it at: https://faststats.dev/abuse\n"
+ "#\n"
+ "# For more information, visit: https://faststats.dev/info\n";
// @Contract(mutates = "io")
public static Config read(final Path file) throws RuntimeException {
return read(file, DEFAULT_COMMENT, false, false);
}
// @Contract(mutates = "io")
public static Config read(final Path file, final String comment, final boolean externallyManaged, final boolean externallyEnabled) throws RuntimeException {
final Optional<Properties> properties = readOrEmpty(file);
final boolean firstRun = !properties.isPresent();
final AtomicBoolean saveConfig = new AtomicBoolean(firstRun);
final UUID serverId = properties.map(object -> object.getProperty("serverId")).map(string -> {
try {
final String trimmed = string.trim();
final String corrected = trimmed.length() > 36 ? trimmed.substring(0, 36) : trimmed;
if (!corrected.equals(string)) saveConfig.set(true);
return UUID.fromString(corrected);
} catch (final IllegalArgumentException e) {
saveConfig.set(true);
return UUID.randomUUID();
}
}).orElseGet(() -> {
saveConfig.set(true);
return UUID.randomUUID();
});
final BiPredicate<String, Boolean> predicate = (key, defaultValue) -> {
return properties.map(object -> object.getProperty(key)).map(Boolean::parseBoolean).orElseGet(() -> {
saveConfig.set(true);
return defaultValue;
});
};
final boolean enabled = externallyManaged ? externallyEnabled : predicate.test("enabled", true);
final boolean errorTracking = predicate.test("submitErrors", false); // InvSee++: changed default from true to false.
final boolean additionalMetrics = predicate.test("submitAdditionalMetrics", true);
final boolean debug = predicate.test("debug", false);
if (saveConfig.get()) try {
save(file, externallyManaged, comment, serverId, enabled, errorTracking, additionalMetrics, debug);
} catch (final IOException e) {
throw new RuntimeException("Failed to save metrics config", e);
}
return new Config(serverId, additionalMetrics, debug, enabled, errorTracking, firstRun, externallyManaged);
}
private static Optional<Properties> readOrEmpty(final Path file) throws RuntimeException {
if (!Files.isRegularFile(file)) return Optional.empty();
try (final BufferedReader reader = Files.newBufferedReader(file, UTF_8)) {
final Properties properties = new Properties();
properties.load(reader);
return Optional.of(properties);
} catch (final IOException e) {
throw new RuntimeException("Failed to read metrics config", e);
}
}
private static void save(final Path file, final boolean externallyManaged, final String comment, final UUID serverId, final boolean enabled, final boolean errorTracking, final boolean additionalMetrics, final boolean debug) throws IOException {
Files.createDirectories(file.getParent());
try (final OutputStream out = Files.newOutputStream(file);
final OutputStreamWriter writer = new OutputStreamWriter(out, UTF_8)) {
final Properties properties = new Properties();
properties.setProperty("serverId", serverId.toString());
if (!externallyManaged) properties.setProperty("enabled", Boolean.toString(enabled));
properties.setProperty("submitErrors", Boolean.toString(errorTracking));
properties.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics));
properties.setProperty("debug", Boolean.toString(debug));
properties.store(writer, comment);
}
}
}
}

View File

@@ -0,0 +1,23 @@
package dev.faststats.core;
import org.intellij.lang.annotations.Pattern;
import org.jetbrains.annotations.NonNls;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
/**
* An annotation to mark a token.
*
* @since 0.1.0
*/
@NonNls
@Pattern(Token.PATTERN)
@Retention(RetentionPolicy.CLASS)
@Target({METHOD, FIELD, PARAMETER, LOCAL_VARIABLE})
public @interface Token {
String PATTERN = "[a-z0-9]{32}";
}

View File

@@ -0,0 +1,26 @@
package dev.faststats.core.data;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import java.util.Optional;
import java.util.concurrent.Callable;
final class ArrayMetric<T> extends SimpleMetric<T[]> {
public ArrayMetric(@SourceId final String id, final Callable<T /*@Nullable*/ []> callable) throws IllegalArgumentException {
super(id, callable);
}
@Override
public Optional<JsonElement> getData() throws Exception {
return compute().map(data -> {
final JsonArray elements = new JsonArray(data.length);
for (final T d : data) {
if (d instanceof Boolean) elements.add((Boolean) d);
else if (d instanceof Number) elements.add((Number) d);
else elements.add(String.valueOf(d));
}
return elements;
});
}
}

View File

@@ -0,0 +1,144 @@
package dev.faststats.core.data;
import com.google.gson.JsonElement;
import java.util.Optional;
import java.util.concurrent.Callable;
/**
* A metric.
*
* @param <T> the metric data type
* @since 0.16.0
*/
public interface Metric<T> {
/**
* Get the source id.
*
* @return the source id
* @since 0.16.0
*/
@SourceId
// @Contract(pure = true)
String getId();
/**
* Compute the metric data.
*
* @return an optional containing the metric data
* @throws Exception if unable to compute the metric data
* @implSpec The implementation must be thread-safe and pure (i.e. not modify any shared state).
* @since 0.16.0
*/
// @Contract(pure = true)
Optional<T> compute() throws Exception;
/**
* Get the metric data as a JSON element.
*
* @return an optional containing the metric data as {@link JsonElement}
* @throws Exception if unable to get the metric data
* @implSpec The implementation must call {@link #compute()} to get the metric data
* and follow the same thread-safety and pureness requirements.
* @see #compute()
* @since 0.16.0
*/
// @Contract(pure = true)
Optional<JsonElement> getData() throws Exception;
/**
* Create a string array metric.
*
* @param id the source id
* @param callable the metric data callable
* @return the string array metric
* @throws IllegalArgumentException if the source id is invalid
* @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state).
* @see #compute()
* @since 0.16.0
*/
// @Contract(value = "_, _ -> new", pure = true)
static Metric<String[]> stringArray(@SourceId final String id, final Callable<String /*@Nullable*/ []> callable) throws IllegalArgumentException {
return new ArrayMetric<>(id, callable);
}
/**
* Create a boolean array metric.
*
* @param id the source id
* @param callable the metric data callable
* @return the boolean array metric
* @throws IllegalArgumentException if the source id is invalid
* @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state).
* @see #compute()
* @since 0.16.0
*/
// @Contract(value = "_, _ -> new", pure = true)
static Metric<Boolean[]> booleanArray(@SourceId final String id, final Callable<Boolean /*@Nullable*/ []> callable) throws IllegalArgumentException {
return new ArrayMetric<>(id, callable);
}
/**
* Create a number array metric.
*
* @param id the source id
* @param callable the metric data callable
* @return the number array metric
* @throws IllegalArgumentException if the source id is invalid
* @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state).
* @see #compute()
* @since 0.16.0
*/
// @Contract(value = "_, _ -> new", pure = true)
static Metric<Number[]> numberArray(@SourceId final String id, final Callable<Number /*@Nullable*/ []> callable) throws IllegalArgumentException {
return new ArrayMetric<>(id, callable);
}
/**
* Create a metric for a boolean value.
*
* @param id the source id
* @param callable the metric data callable
* @return the boolean metric
* @throws IllegalArgumentException if the source id is invalid
* @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state).
* @see #compute()
* @since 0.16.0
*/
// @Contract(value = "_, _ -> new", pure = true)
static Metric<Boolean> bool(@SourceId final String id, final Callable</*@Nullable*/ Boolean> callable) throws IllegalArgumentException {
return new SingleValueMetric<>(id, callable);
}
/**
* Create a metric for a string value.
*
* @param id the source id
* @param callable the metric data callable
* @return the string metric
* @throws IllegalArgumentException if the source id is invalid
* @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state).
* @see #compute()
* @since 0.16.0
*/
// @Contract(value = "_, _ -> new", pure = true)
static Metric<String> string(@SourceId final String id, final Callable</*@Nullable*/ String> callable) throws IllegalArgumentException {
return new SingleValueMetric<>(id, callable);
}
/**
* Create a metric for a number value.
*
* @param id the source id
* @param callable the metric data callable
* @return the number metric
* @throws IllegalArgumentException if the source id is invalid
* @apiNote The callable must be thread-safe and pure (i.e. not modify any shared state).
* @see #compute()
* @since 0.16.0
*/
// @Contract(value = "_, _ -> new", pure = true)
static Metric<Number> number(@SourceId final String id, final Callable</*@Nullable*/ Number> callable) throws IllegalArgumentException {
return new SingleValueMetric<>(id, callable);
}
}

View File

@@ -0,0 +1,46 @@
package dev.faststats.core.data;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Callable;
abstract class SimpleMetric<T> implements Metric<T> {
private final @SourceId String id;
private final Callable</*@Nullable*/ T> callable;
public SimpleMetric(@SourceId final String id, final Callable</*@Nullable*/ T> callable) throws IllegalArgumentException {
if (!id.matches(SourceId.PATTERN)) {
throw new IllegalArgumentException("Invalid source id '" + id + "', must match '" + SourceId.PATTERN + "'");
}
this.id = id;
this.callable = callable;
}
@Override
public final @SourceId String getId() {
return id;
}
public final Optional<T> compute() throws Exception {
return Optional.ofNullable(callable.call());
}
@Override
public boolean equals(final Object o) {
if (o == null || getClass() != o.getClass()) return false;
final SimpleMetric<?> that = (SimpleMetric<?>) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
@Override
public String toString() {
return "SimpleMetric{" +
"id='" + id + '\'' +
'}';
}
}

View File

@@ -0,0 +1,22 @@
package dev.faststats.core.data;
import com.google.gson.JsonElement;
import com.google.gson.JsonPrimitive;
import java.util.Optional;
import java.util.concurrent.Callable;
final class SingleValueMetric<T> extends SimpleMetric<T> {
public SingleValueMetric(@SourceId final String id, final Callable</*@Nullable*/ T> callable) throws IllegalArgumentException {
super(id, callable);
}
@Override
public Optional<JsonElement> getData() throws Exception {
return compute().map(data -> {
if (data instanceof Boolean) return new JsonPrimitive((Boolean) data);
if (data instanceof Number) return new JsonPrimitive((Number) data);
return new JsonPrimitive(data.toString());
});
}
}

View File

@@ -0,0 +1,20 @@
package dev.faststats.core.data;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.*;
/**
* An annotation to mark a source id.
*
* @since 0.16.0
*/
//@NonNls
//@Pattern(SourceId.PATTERN)
@Retention(RetentionPolicy.CLASS)
@Target({METHOD, FIELD, PARAMETER, LOCAL_VARIABLE})
public @interface SourceId {
String PATTERN = "[a-z_]+";
}