Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
47e3834
Refactor feature flags
NonSwag Apr 17, 2026
7f2d1ed
Split metrics and flags url into separate values
NonSwag Apr 19, 2026
7361407
Throw on non-finite numbers
NonSwag Apr 19, 2026
971163c
Replace Object with JsonPrimitive in Attributes
NonSwag Apr 19, 2026
fd2d4c6
Add Type enum to FeatureFlags
NonSwag Apr 19, 2026
6cdb5c9
Refactor SimpleFeatureFlagService
NonSwag Apr 19, 2026
b22d3f0
Generalize terms in onboarding message and default config
NonSwag Apr 19, 2026
3d1bb9a
Refactored logging
NonSwag Apr 19, 2026
59a4cdf
Removed settings and ability to define metrics URL and debug
NonSwag Apr 19, 2026
d679ef8
Document FeatureFlags
NonSwag Apr 19, 2026
1713745
Document Attributes#forEachPrimitive
NonSwag Apr 19, 2026
4652232
Use correct url
NonSwag Apr 19, 2026
02d3fd3
Make SDK properties static
NonSwag Apr 19, 2026
b603223
Add minimal logger api
NonSwag Apr 19, 2026
598cc2d
Extracts constants to its own class
NonSwag Apr 19, 2026
3e990b2
Add dedicated Hytale logger
NonSwag Apr 19, 2026
808d69c
Use custom filter predicate
NonSwag Apr 19, 2026
d268a7f
Move logger below metrics server url
NonSwag Apr 19, 2026
3201b48
Removed unused imports
NonSwag Apr 19, 2026
a0b8bb6
Replace Gson#toJson with toString
NonSwag Apr 19, 2026
30b554f
Add logger to feature flag service
NonSwag Apr 19, 2026
4e1b50a
Decouple config from Metrics interface
NonSwag Apr 19, 2026
dd9266c
Undo happy little accident :)
NonSwag Apr 19, 2026
0f59272
Add info comments to example
NonSwag Apr 19, 2026
4c5d9bd
Throw on negative ttl
NonSwag Apr 19, 2026
631067c
Add attributes and TTL getters
NonSwag Apr 19, 2026
08c756b
Refactor URL retrieval
NonSwag Apr 19, 2026
cfe4a1f
Add `getLogger(Class)` overload
NonSwag Apr 19, 2026
a32d6c0
Decouple metrics and feature flags
NonSwag Apr 19, 2026
13b8a42
Cancel all running fetches on shutdown
NonSwag Apr 19, 2026
50ca978
Retrieve server id from config
NonSwag Apr 19, 2026
45d761b
Unseal config
NonSwag Apr 19, 2026
375efbc
Update config comment
NonSwag Apr 19, 2026
84b4740
Very elegant but sounds stupid
NonSwag Apr 19, 2026
bcf6c91
Prepare for config impl extraction
NonSwag Apr 19, 2026
700ce5b
todo
NonSwag Apr 19, 2026
1b9dfef
Extract config impl to separate module
NonSwag Apr 19, 2026
b824d32
Update plugin application code
NonSwag Apr 19, 2026
9e2cfc8
Refactor config handling
NonSwag Apr 20, 2026
cdf2e16
Major metrics schema refactor
NonSwag Apr 20, 2026
3a02f88
Simplified metrics construction flow overhead
NonSwag Apr 21, 2026
5f72967
Added injection support for platform context
NonSwag Apr 21, 2026
42a4bb5
Update examples to reflect the current best practices
NonSwag Apr 21, 2026
21d157b
Pass the server id to feature flag service
NonSwag Apr 21, 2026
43b7726
Document SimpleContext constructor
NonSwag Apr 21, 2026
7a9ebba
Fix happy little accident
NonSwag Apr 21, 2026
3e6a08b
Add more test coverage and fixed awful smoke tests
NonSwag Apr 21, 2026
439a2fc
Add fabric client support
NonSwag Apr 21, 2026
20d29b7
Stacktrace fingerprinting
NonSwag Apr 21, 2026
ec1d11c
Rename hash method to hash128 and replace JsonObject with String para…
NonSwag Apr 21, 2026
8186a3d
Rename isLibraryClass to isLibraryFrame
NonSwag Apr 21, 2026
b4a87a3
Integrate stacktrace fingerprinting
NonSwag Apr 21, 2026
b04c840
Add stacktrace fingerprinting tests
NonSwag Apr 21, 2026
8e8447b
Fix inverted condition
NonSwag Apr 22, 2026
6de87a7
Add method contracts
NonSwag Apr 22, 2026
dc52d5a
Do not fetch eagerly
NonSwag Apr 22, 2026
779c616
No need to cache the logger
NonSwag Apr 22, 2026
62dbe9d
Reword feature-flags virtual constructor javadocs description
NonSwag Apr 22, 2026
3a6f395
Move fetch times and cache to flag implementation
NonSwag Apr 22, 2026
bacef36
Add logger name to error log record
NonSwag Apr 22, 2026
f7a2bba
Add debug logs to feature flag fetches
NonSwag Apr 22, 2026
c47a132
Note exceptional behavior for feature flag fetches
NonSwag Apr 22, 2026
fbbbb97
Link to #fetch
NonSwag Apr 22, 2026
a4f4478
Link to #whenReady
NonSwag Apr 22, 2026
02adc69
Clarify #getChaged docs
NonSwag Apr 22, 2026
592959f
Simplified code structure
NonSwag Apr 22, 2026
a91722c
Update feature flags example
NonSwag Apr 22, 2026
dfc38e9
Fix url resolving
NonSwag Apr 22, 2026
f76c78a
Rename serverId to identifier
NonSwag Apr 22, 2026
977f037
Add more debug logs
NonSwag Apr 22, 2026
bc265eb
Simplify opt request callback
NonSwag Apr 24, 2026
409f3df
Simplify error tracker entry creation
NonSwag Apr 29, 2026
18e35b9
Rename reported "hash" to "group_hash"
NonSwag Apr 29, 2026
14fdf5d
remove useless test
NonSwag Apr 29, 2026
1f4edb2
Remove opt-in and out API
NonSwag Apr 29, 2026
662b8bb
Improve number and boolean parsing
NonSwag May 1, 2026
32ffa48
Remove error fingerprinting
NonSwag May 5, 2026
79f1497
Revert "Remove opt-in and out API"
NonSwag May 20, 2026
a9c6267
Add feature flag tests
NonSwag May 20, 2026
76dc9ec
Add method contracts to implementations
NonSwag May 20, 2026
d5421f8
Remove #needsFlushing
NonSwag May 21, 2026
456b80c
Rename `FastStatsContext#metrics` to `#metricsFactory`
NonSwag May 25, 2026
492d0f7
Couple error tracker and context
NonSwag May 25, 2026
584455a
Bump version to 0.24.0
NonSwag May 25, 2026
fc60031
Move error tracker to platform context
NonSwag May 25, 2026
0d33a39
Add package info
NonSwag May 25, 2026
96c9915
Delete unused test
NonSwag May 25, 2026
d769325
Remove murmur hash algorythm
NonSwag May 25, 2026
d40cd51
Add feature flag service factory
NonSwag May 25, 2026
5c98885
Update package
NonSwag May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ val javaVersionsOverride = mapOf(
val defaultJavaVersion = 17

subprojects {
apply(plugin = "java")
apply(plugin = "java-library")
apply {
plugin("java")
plugin("java-library")
}

val example = project.name.startsWith("example")
if (example) {
apply(plugin = "com.gradleup.shadow")
val noPublish = project.name.startsWith("example") || project.name == "config"
if (noPublish) {
apply { plugin("com.gradleup.shadow") }
} else {
apply(plugin = "maven-publish")
apply { plugin("maven-publish") }
}

group = "dev.faststats.metrics"
Expand All @@ -51,7 +53,7 @@ subprojects {
doLast {
val file = outputDir.get().file("META-INF/faststats.properties").asFile
file.parentFile.mkdirs()
file.writeText("name=${project.name}\nversion=${project.version}\n")
file.writeText("version=${project.version}\n")
}
}

Expand Down Expand Up @@ -94,7 +96,7 @@ subprojects {
}

afterEvaluate {
if (example) return@afterEvaluate
if (noPublish) return@afterEvaluate
extensions.configure<PublishingExtension> {
publications.create<MavenPublication>("maven") {
artifactId = project.name
Expand Down
1 change: 1 addition & 0 deletions bukkit/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ configurations.compileClasspath {

dependencies {
api(project(":core"))
implementation(project(":config"))
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
}
63 changes: 13 additions & 50 deletions bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java
Original file line number Diff line number Diff line change
@@ -1,56 +1,28 @@
package com.example;

import dev.faststats.ErrorTracker;
import dev.faststats.bukkit.BukkitContext;
import dev.faststats.bukkit.BukkitMetrics;
import dev.faststats.core.ErrorTracker;
import dev.faststats.core.data.Metric;
import dev.faststats.data.Metric;
import org.bukkit.plugin.java.JavaPlugin;

import java.lang.reflect.InvocationTargetException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.concurrent.atomic.AtomicInteger;

public class ExamplePlugin extends JavaPlugin {
// context-aware error tracker, automatically tracks errors in the same class loader
public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware()
// Ignore specific errors and messages
.ignoreError(InvocationTargetException.class, "Expected .* but got .*") // Ignored an error with a message
.ignoreError(AccessDeniedException.class); // Ignored a specific error type

// context-unaware error tracker, does not automatically track errors
public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware()
// Anonymize error messages if required
.anonymize("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,4}$", "[email hidden]") // Email addresses
.anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [token hidden]") // Bearer tokens in error messages
.anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]") // AWS access key IDs
.anonymize("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "[uuid hidden]") // UUIDs (e.g. session/user IDs)
.anonymize("([?&](?:api_?key|token|secret)=)[^&\\s]+", "$1[redacted]"); // API keys in query strings

public final class ExamplePlugin extends JavaPlugin {
private final AtomicInteger gameCount = new AtomicInteger();
private final BukkitContext context = new BukkitContext(this, "YOUR_TOKEN_HERE");
public final ErrorTracker errorTracker = context.awareErrorTracker();

private final BukkitMetrics metrics = BukkitMetrics.factory()
.url(URI.create("https://metrics.example.com/v1/collect")) // For self-hosted metrics servers only

// Custom example metrics
// For this to work you have to create a corresponding data source in your project settings first
.addMetric(Metric.number("example_metric", () -> 42))
private final BukkitMetrics metrics = context.metricsFactory()
// Custom metrics require a corresponding data source in your project settings
.addMetric(Metric.number("game_count", gameCount::get))
.addMetric(Metric.string("example_string", () -> "Hello, World!"))
.addMetric(Metric.bool("example_boolean", () -> true))
.addMetric(Metric.stringArray("example_string_array", () -> new String[]{"Option 1", "Option 2"}))
.addMetric(Metric.numberArray("example_number_array", () -> new Number[]{1, 2, 3}))
.addMetric(Metric.booleanArray("example_boolean_array", () -> new Boolean[]{true, false}))

// Attach an error tracker
// This must be enabled in the project settings
.errorTracker(ERROR_TRACKER)
.addMetric(Metric.string("server_version", () -> "1.0.0"))

.onFlush(() -> gameCount.set(0)) // Reset game count on flush
// #onFlush is invoked after successful metrics submission
// This is useful for cleaning up cached data
.onFlush(() -> gameCount.set(0)) // reset game count on flush

.debug(true) // Enable debug mode for development and testing

.token("YOUR_TOKEN_HERE") // required -> token can be found in the settings of your project
.create(this);
.create();

@Override
public void onEnable() {
Expand All @@ -62,15 +34,6 @@ public void onDisable() {
metrics.shutdown(); // safely shut down metrics submission
}

public void doSomethingWrong() {
try {
// Do something that might throw an error
throw new RuntimeException("Something went wrong!");
} catch (final Exception e) {
CONTEXT_UNAWARE_ERROR_TRACKER.trackError(e);
}
}

public void startGame() {
gameCount.incrementAndGet();
}
Expand Down
41 changes: 41 additions & 0 deletions bukkit/src/main/java/dev/faststats/bukkit/BukkitContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package dev.faststats.bukkit;

import dev.faststats.SimpleContext;
import dev.faststats.Token;
import dev.faststats.config.SimpleConfig;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.Contract;

import java.nio.file.Path;

/**
* Bukkit FastStats context.
*
* @since 0.24.0
*/
public final class BukkitContext extends SimpleContext {
final Plugin plugin;

public BukkitContext(final Plugin plugin, @Token final String token) {
super(SimpleConfig.read(getConfigPath(plugin)), "bukkit", token);
this.plugin = plugin;
}

@Override
@Contract(value = " -> new", pure = true)
public BukkitMetrics.Factory metricsFactory() {
return new BukkitMetricsImpl.Factory(this);
}

private static Path getConfigPath(final Plugin plugin) {
return getPluginsFolder(plugin).resolve("faststats").resolve("config.properties");
}

private static Path getPluginsFolder(final Plugin plugin) {
try {
return plugin.getServer().getPluginsFolder().toPath();
} catch (final NoSuchMethodError e) {
return plugin.getDataFolder().getParentFile().toPath();
}
}
}
25 changes: 10 additions & 15 deletions bukkit/src/main/java/dev/faststats/bukkit/BukkitMetrics.java
Original file line number Diff line number Diff line change
@@ -1,27 +1,16 @@
package dev.faststats.bukkit;

import dev.faststats.core.Metrics;
import dev.faststats.Metrics;
import dev.faststats.data.Metric;
import org.bukkit.plugin.IllegalPluginAccessException;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.Contract;

/**
* Bukkit metrics implementation.
*
* @since 0.1.0
*/
public sealed interface BukkitMetrics extends Metrics permits BukkitMetricsImpl {
/**
* 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.
*
Expand All @@ -32,8 +21,14 @@ static Factory factory() {
@Override
void ready() throws IllegalPluginAccessException;

interface Factory extends Metrics.Factory<Plugin, Factory> {
sealed interface Factory extends Metrics.Factory permits BukkitMetricsImpl.Factory {
@Override
Factory addMetric(Metric<?> metric) throws IllegalArgumentException;

@Override
Factory onFlush(Runnable flush);

@Override
BukkitMetrics create(Plugin object) throws IllegalStateException;
BukkitMetrics create() throws IllegalStateException;
}
}
64 changes: 29 additions & 35 deletions bukkit/src/main/java/dev/faststats/bukkit/BukkitMetricsImpl.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
package dev.faststats.bukkit;

import com.google.gson.JsonObject;
import dev.faststats.core.SimpleMetrics;
import dev.faststats.SimpleMetrics;
import dev.faststats.config.SimpleConfig;
import dev.faststats.data.Metric;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.Async;
import org.jetbrains.annotations.Contract;
import org.jspecify.annotations.Nullable;

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;
Expand All @@ -22,8 +21,8 @@ final class BukkitMetricsImpl extends SimpleMetrics implements BukkitMetrics {
@Async.Schedule
@Contract(mutates = "io")
@SuppressWarnings({"deprecation", "Convert2MethodRef"})
private BukkitMetricsImpl(final Factory factory, final Plugin plugin, final Path config) throws IllegalStateException {
super(factory, config);
private BukkitMetricsImpl(final Factory factory, final Plugin plugin) throws IllegalStateException {
super(factory);

this.plugin = plugin;
final var server = plugin.getServer();
Expand Down Expand Up @@ -63,6 +62,11 @@ private boolean isProxyOnlineMode() {
return settings.getBoolean("bungeecord") && proxies.getBoolean("bungee-cord.online-mode");
}

@Override
protected boolean preSubmissionStart() {
return ((SimpleConfig) context.getConfig()).preSubmissionStart();
}

@Override
protected void appendDefaultData(final JsonObject metrics) {
metrics.addProperty("minecraft_version", minecraftVersion);
Expand All @@ -76,31 +80,17 @@ private int getPlayerCount() {
try {
return plugin.getServer().getOnlinePlayers().size();
} catch (final Throwable t) {
error("Failed to get player count", t);
logger.error("Failed to get player count", t);
// todo: track error?
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 {
try {
Class.forName("com.destroystokyo.paper.event.server.ServerExceptionEvent");
plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(this), plugin);
plugin.getServer().getPluginManager().registerEvents(new PaperEventListener(plugin, context), plugin);
} catch (final ClassNotFoundException ignored) {
}
}
Expand All @@ -113,20 +103,24 @@ private <T> Optional<T> tryOrEmpty(final Supplier<T> supplier) {
}
}

static final class Factory extends SimpleMetrics.Factory<Plugin, BukkitMetrics.Factory> implements BukkitMetrics.Factory {
public static final class Factory extends SimpleMetrics.Factory implements BukkitMetrics.Factory {
Factory(final BukkitContext context) {
super(context);
}

@Override
public BukkitMetrics create(final Plugin plugin) throws IllegalStateException {
final var dataFolder = getPluginsFolder(plugin).resolve("faststats");
final var config = dataFolder.resolve("config.properties");
return new BukkitMetricsImpl(this, plugin, config);
public Factory addMetric(final Metric<?> metric) throws IllegalArgumentException {
return (Factory) super.addMetric(metric);
}

private static Path getPluginsFolder(final Plugin plugin) {
try {
return plugin.getServer().getPluginsFolder().toPath();
} catch (final NoSuchMethodError e) {
return plugin.getDataFolder().getParentFile().toPath();
}
@Override
public Factory onFlush(final Runnable flush) {
return (Factory) super.onFlush(flush);
}

@Override
public BukkitMetrics create() throws IllegalStateException {
return new BukkitMetricsImpl(this, ((BukkitContext) context).plugin);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

import com.destroystokyo.paper.event.server.ServerExceptionEvent;
import com.destroystokyo.paper.exception.ServerPluginException;
import dev.faststats.SimpleContext;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.plugin.Plugin;

record PaperEventListener(BukkitMetricsImpl metrics) implements Listener {
record PaperEventListener(Plugin plugin, SimpleContext context) implements Listener {
@EventHandler(priority = EventPriority.MONITOR)
public void onServerException(final ServerExceptionEvent event) {
if (!(event.getException() instanceof final ServerPluginException exception)) return;
if (!exception.getResponsiblePlugin().equals(metrics.plugin())) return;
if (!exception.getResponsiblePlugin().equals(plugin)) return;
final var report = exception.getCause() != null ? exception.getCause() : exception;
metrics.getErrorTracker().ifPresent(tracker -> tracker.trackError(report, false));
context.errorTrackers().forEach(tracker -> tracker.trackError(report, false));
}
}
5 changes: 3 additions & 2 deletions bukkit/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
exports dev.faststats.bukkit;

requires com.google.gson;
requires dev.faststats.core;
requires dev.faststats.config;
requires dev.faststats;
requires java.logging;
requires org.bukkit;

requires static org.jetbrains.annotations;
requires static org.jspecify;
}
}
1 change: 1 addition & 0 deletions bungeecord/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ repositories {

dependencies {
api(project(":core"))
implementation(project(":config"))
compileOnly("net.md-5:bungeecord-api:26.1-R0.1-SNAPSHOT")
}
Loading