govno kod
Some checks failed
Build / build (push) Failing after 3h3m20s
Some checks failed
Build / build (push) Failing after 3h3m20s
This commit is contained in:
13
FastStats/.gitignore
vendored
Normal file
13
FastStats/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#IntelliJ
|
||||
.idea/*
|
||||
**.iml
|
||||
|
||||
#Maven
|
||||
target/*
|
||||
|
||||
#Eclipse
|
||||
**/.settings/*
|
||||
**/.project
|
||||
**/.classpath
|
||||
|
||||
/target/
|
||||
5
FastStats/README.md
Normal file
5
FastStats/README.md
Normal 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
74
FastStats/pom.xml
Normal 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>
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
222
FastStats/src/main/java/dev/faststats/core/ErrorHelper.java
Normal file
222
FastStats/src/main/java/dev/faststats/core/ErrorHelper.java
Normal 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);
|
||||
}
|
||||
}
|
||||
240
FastStats/src/main/java/dev/faststats/core/ErrorTracker.java
Normal file
240
FastStats/src/main/java/dev/faststats/core/ErrorTracker.java
Normal 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);
|
||||
}
|
||||
}
|
||||
218
FastStats/src/main/java/dev/faststats/core/Metrics.java
Normal file
218
FastStats/src/main/java/dev/faststats/core/Metrics.java
Normal 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();
|
||||
}
|
||||
}
|
||||
187
FastStats/src/main/java/dev/faststats/core/MurmurHash3.java
Normal file
187
FastStats/src/main/java/dev/faststats/core/MurmurHash3.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
509
FastStats/src/main/java/dev/faststats/core/SimpleMetrics.java
Normal file
509
FastStats/src/main/java/dev/faststats/core/SimpleMetrics.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
FastStats/src/main/java/dev/faststats/core/Token.java
Normal file
23
FastStats/src/main/java/dev/faststats/core/Token.java
Normal 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}";
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
144
FastStats/src/main/java/dev/faststats/core/data/Metric.java
Normal file
144
FastStats/src/main/java/dev/faststats/core/data/Metric.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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_]+";
|
||||
}
|
||||
Reference in New Issue
Block a user