Implement telemetry system with data collection and reporting
Java CI / build (push) Successful in 56s
.NET+Docker CI/CD / Unit and Integration tests (push) Successful in 38s
.NET+Docker CI/CD / Push Docker image to ghcr.io (push) Failing after 44s

Signed-off-by: Dmitrii <computer@yawaflua.tech>

Took 20 minutes
This commit is contained in:
Dmitrii
2026-07-11 00:38:28 +03:00
parent f3a2a5d463
commit 0e53d7dd8c
33 changed files with 1245 additions and 41 deletions
@@ -35,6 +35,10 @@ public class ModMenuIntegration implements ModMenuApi {
final boolean[] signQuickPayEnabled = {current.signQuickPayEnabled()};
final GpsHudPosition[] gpsPosition = {current.gpsPosition()};
final boolean[] telemetryEnabled = {current.telemetryEnabled()};
final int[] telemetryIntervalSeconds = {current.telemetryIntervalSeconds()};
final boolean[] telemetryCollectSystemInfo = {current.telemetryCollectSystemInfo()};
general.addEntry(entryBuilder.startStrField(Text.translatable("option.spmega.api_domain"), current.apiDomain())
.setDefaultValue(ModConfig.DEFAULT_API_DOMAIN)
.setSaveConsumer(newValue -> apiDomain[0] = newValue)
@@ -80,6 +84,29 @@ public class ModMenuIntegration implements ModMenuApi {
.setSaveConsumer(newValue -> gpsPosition[0] = newValue)
.build());
// Telemetry settings
ConfigCategory telemetry = builder.getOrCreateCategory(Text.translatable("category.spmega.telemetry"));
telemetry.addEntry(entryBuilder.startBooleanToggle(Text.translatable("option.spmega.telemetry_enabled"), current.telemetryEnabled())
.setDefaultValue(ModConfig.DEFAULT_TELEMETRY_ENABLED)
.setTooltip(Text.translatable("tooltip.spmega.telemetry_enabled"))
.setSaveConsumer(newValue -> telemetryEnabled[0] = newValue)
.build());
telemetry.addEntry(entryBuilder.startIntField(Text.translatable("option.spmega.telemetry_interval"), current.telemetryIntervalSeconds())
.setDefaultValue(ModConfig.DEFAULT_TELEMETRY_INTERVAL_SECONDS)
.setMin(10)
.setMax(600)
.setTooltip(Text.translatable("tooltip.spmega.telemetry_interval"))
.setSaveConsumer(newValue -> telemetryIntervalSeconds[0] = newValue)
.build());
telemetry.addEntry(entryBuilder.startBooleanToggle(Text.translatable("option.spmega.telemetry_system_info"), current.telemetryCollectSystemInfo())
.setDefaultValue(ModConfig.DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO)
.setTooltip(Text.translatable("tooltip.spmega.telemetry_system_info"))
.setSaveConsumer(newValue -> telemetryCollectSystemInfo[0] = newValue)
.build());
builder.setSavingRunnable(() -> {
boolean gpsEnabledVal = true;
if (SPMega.getConfig() != null) {
@@ -91,7 +118,10 @@ public class ModMenuIntegration implements ModMenuApi {
allowBackend[0],
signQuickPayEnabled[0],
gpsEnabledVal,
gpsPosition[0]
gpsPosition[0],
telemetryEnabled[0],
telemetryIntervalSeconds[0],
telemetryCollectSystemInfo[0]
);
SPMega.setConfig(updated);
});
@@ -3,6 +3,7 @@ package git.yawaflua.tech.spmega.client;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.qr.QRCodeScanner;
import git.yawaflua.tech.spmega.client.telemetry.*;
import git.yawaflua.tech.spmega.client.ui.GpsHudRenderer;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import git.yawaflua.tech.spmega.client.ui.UiOpeners;
@@ -100,6 +101,7 @@ public class SPMegaClient implements ClientModInitializer {
}
ClientTickEvents.END_CLIENT_TICK.register(client -> {
PerformanceSampler.instance().tick();
UiNotifications.instance().tick();
while (openBankMenuKeyBinding.wasPressed()) {
UiOpeners.openMainMenu(client);
@@ -117,7 +119,10 @@ public class SPMegaClient implements ClientModInitializer {
current.allowBackend(),
current.signQuickPayEnabled(),
GpsHudRenderer.instance().isEnabled(),
current.gpsPosition()
current.gpsPosition(),
current.telemetryEnabled(),
current.telemetryIntervalSeconds(),
current.telemetryCollectSystemInfo()
);
SPMega.setConfig(updated);
}
@@ -232,6 +237,27 @@ public class SPMegaClient implements ClientModInitializer {
System.err.println("[SPMEGA] Error during async authenticate: " + throwable.getMessage());
return null;
});
// Telemetry init
long clientInitStart = System.nanoTime();
ModConfig cfg = SPMega.getConfig();
int interval = (cfg != null) ? cfg.telemetryIntervalSeconds() : ModConfig.DEFAULT_TELEMETRY_INTERVAL_SECONDS;
SystemInfoCollector.instance().init();
TelemetrySender.instance().start(interval);
long clientInitMs = (System.nanoTime() - clientInitStart) / 1_000_000L;
com.google.gson.JsonObject initPayload = new com.google.gson.JsonObject();
initPayload.addProperty("phase", "client_init");
initPayload.addProperty("durationMs", clientInitMs);
TelemetryCollector.instance().record(TelemetryEvent.now("lifecycle", initPayload));
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
com.google.gson.JsonObject deinitPayload = new com.google.gson.JsonObject();
deinitPayload.addProperty("phase", "client_deinit");
TelemetryCollector.instance().record(TelemetryEvent.now("lifecycle", deinitPayload));
TelemetrySender.instance().flush();
TelemetrySender.instance().stop();
}, "SPMega-Telemetry-Shutdown"));
System.out.println("Author of SPMega make it with 4 cans of monster");
System.out.println("If u want to see more updates - give me like 10 shekels for monster plzzz");
System.out.println("Initialized beshalom! Tieie tovim!");
@@ -0,0 +1,199 @@
package git.yawaflua.tech.spmega.client.api;
import com.google.gson.*;
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public final class SPWorldsApiClient {
private final InstrumentedHttpClient httpClient;
private final Gson gson;
private final String baseUrl;
public SPWorldsApiClient(String baseUrl) {
this.httpClient = new InstrumentedHttpClient();
this.gson = new Gson();
this.baseUrl = normalizeBaseUrl(baseUrl);
}
private static String encodeAuth(CardAuth auth) {
String raw = auth.cardId() + ":" + auth.cardToken();
return Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}
private static String normalizeBaseUrl(String rawBaseUrl) {
String fallback = "https://spworlds.ru";
if (rawBaseUrl == null || rawBaseUrl.trim().isEmpty()) {
return fallback;
}
String value = rawBaseUrl.trim();
if (value.endsWith("/")) {
return value.substring(0, value.length() - 1);
}
return value;
}
private static String getString(JsonObject json, String key) {
if (!json.has(key) || json.get(key).isJsonNull()) {
return "";
}
return json.get(key).getAsString();
}
public CardInfo getCardInfo(CardAuth auth) throws IOException, InterruptedException {
HttpRequest request = requestBuilder("/api/public/card", auth).GET().build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
if (json.has("statusCode")) {
switch (json.get("statusCode").getAsInt()) {
case 403:
throw new IOException("Апи вернула ошибку: " + json.get("message").getAsString());
default:
System.out.println("Unhandled status code in card info response: " + json.get("statusCode").getAsInt());
break;
}
}
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
String webhook = json.has("webhook") && !json.get("webhook").isJsonNull()
? json.get("webhook").getAsString()
: "";
return new CardInfo(balance, webhook);
} catch (Exception e) {
System.out.println("Failed to parse card info response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse card info response", e);
}
}
public List<PlayerCard> getPlayerCards(String username, CardAuth auth) throws IOException, InterruptedException {
HttpRequest request = requestBuilder("/api/public/accounts/" + username + "/cards", auth).GET().build();
String body = send(request);
try {
JsonArray json = JsonParser.parseString(body).getAsJsonArray();
List<PlayerCard> cards = new ArrayList<>();
for (JsonElement element : json) {
JsonObject card = element.getAsJsonObject();
String name = card.has("name") ? card.get("name").getAsString() : "";
String number = card.has("number") ? card.get("number").getAsString() : "";
cards.add(new PlayerCard(name, number));
}
return cards;
} catch (Exception e) {
System.out.println("Failed to parse player cards response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse player cards response", e);
}
}
public AccountMe getAccountMe(CardAuth auth) throws IOException, InterruptedException {
HttpRequest request = requestBuilder("/api/public/accounts/me", auth).GET().build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
String id = json.has("id") ? json.get("id").getAsString() : "";
String username = json.has("username") ? json.get("username").getAsString() : "";
String minecraftUuid = json.has("minecraftUUID") ? json.get("minecraftUUID").getAsString() : "";
List<AccountCard> cards = new ArrayList<>();
if (json.has("cards") && json.get("cards").isJsonArray()) {
for (JsonElement element : json.getAsJsonArray("cards")) {
JsonObject card = element.getAsJsonObject();
cards.add(new AccountCard(
getString(card, "id"),
getString(card, "name"),
getString(card, "number"),
card.has("color") && !card.get("color").isJsonNull() ? card.get("color").getAsInt() : 0
));
}
}
return new AccountMe(id, username, minecraftUuid, cards);
} catch (Exception e) {
System.out.println("Failed to parse account info response: " + e.getMessage());
System.out.println(body);
throw new IOException("Failed to parse account info response", e);
}
}
public TransactionResult createTransaction(CardAuth auth, String receiver, long amount, String comment)
throws IOException, InterruptedException {
JsonObject payload = new JsonObject();
payload.addProperty("receiver", receiver);
payload.addProperty("amount", amount);
payload.addProperty("comment", comment.isEmpty() ? "Перевод через SPMega" : comment);
HttpRequest request = requestBuilder("/api/public/transactions", auth)
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
.build();
String body = send(request);
try {
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
long balance = json.has("balance") ? json.get("balance").getAsLong() : 0L;
return new TransactionResult(balance);
} catch (Exception exception) {
System.out.println("Failed to parse transaction response: " + exception.getMessage());
System.out.println(body);
throw new IOException("Failed to parse transaction response", exception);
}
}
private HttpRequest.Builder requestBuilder(String path, CardAuth auth) {
return HttpRequest.newBuilder(URI.create(baseUrl + path))
.header("Authorization", "Bearer " + encodeAuth(auth))
.header("Content-Type", "application/json")
.header("Accept", "application/json");
}
private String send(HttpRequest request) throws IOException, InterruptedException {
HttpResponse<String> response = httpClient.send(request);
int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
System.out.println(response.body());
JsonElement parsed = JsonParser.parseString(response.body());
JsonObject object = parsed.isJsonArray() ? parsed.getAsJsonArray().get(0).getAsJsonObject() : parsed.getAsJsonObject();
var message = "";
if (object.has("error") && !object.get("error").isJsonNull()) {
message = "Ошибка в запросе: " + object.get("error").getAsString();
} else if (object.has("message") && !object.get("message").isJsonNull()) {
message = object.get("message").getAsString();
}
throw new IOException(statusCode + ": " + message);
}
return response.body();
}
public record CardAuth(String cardId, String cardToken) {
}
public record CardInfo(long balance, String webhook) {
}
public record PlayerCard(String name, String number) {
}
public record AccountMe(String id, String username, String minecraftUuid, List<AccountCard> cards) {
}
public record AccountCard(String id, String name, String number, int color) {
}
public record TransactionResult(long balance) {
}
}
@@ -4,6 +4,8 @@ import com.google.zxing.*;
import com.google.zxing.common.GlobalHistogramBinarizer;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.multi.GenericMultipleBarcodeReader;
import git.yawaflua.tech.spmega.client.telemetry.TelemetryCollector;
import git.yawaflua.tech.spmega.client.telemetry.TelemetryEvent;
import git.yawaflua.tech.spmega.client.ui.QRcodeAcceptScreen;
import git.yawaflua.tech.spmega.client.ui.UiNotifications;
import net.minecraft.client.MinecraftClient;
@@ -30,6 +32,7 @@ public class QRCodeScanner {
return;
}
long startNs = System.nanoTime();
try {
ScreenshotRecorder.takeScreenshot(client.getFramebuffer(), nativeImage -> {
try {
@@ -39,10 +42,13 @@ public class QRCodeScanner {
notifications.show(Text.translatable("message.spmega.qr.capture_failed"));
}
});
recordQrEvent(false, false, null, "capture_failed", startNs);
return;
}
String result = decodeQRCode(nativeImageToBufferedImage(nativeImage));
long scanNs = System.nanoTime();
boolean didLag = (scanNs - startNs) > 50_000_000L;
client.execute(() -> {
if (client.player == null) {
return;
@@ -57,6 +63,7 @@ public class QRCodeScanner {
client.player.sendMessage(Text.translatable("message.spmega.qr.found_link", clickableLink), false);
client.setScreen(new QRcodeAcceptScreen(result, client.currentScreen));
});
recordQrEvent(result != null, didLag, result, null, startNs);
} finally {
if (nativeImage != null) {
nativeImage.close();
@@ -65,9 +72,24 @@ public class QRCodeScanner {
});
} catch (Exception ex) {
notifications.show(Text.translatable("message.spmega.qr.error"));
recordQrEvent(false, false, null, ex.getClass().getSimpleName() + ": " + ex.getMessage(), startNs);
}
}
private static void recordQrEvent(boolean success, boolean didLag, String decodedUrl, String error, long startNs) {
com.google.gson.JsonObject payload = new com.google.gson.JsonObject();
payload.addProperty("success", success);
payload.addProperty("didLag", didLag);
payload.addProperty("durationMs", (System.nanoTime() - startNs) / 1_000_000L);
if (decodedUrl != null) {
payload.addProperty("decodedUrl", decodedUrl);
}
if (error != null) {
payload.addProperty("error", error);
}
TelemetryCollector.instance().record(TelemetryEvent.now("qr_scan", payload));
}
private static BufferedImage nativeImageToBufferedImage(NativeImage screenshot) {
BufferedImage bufferedImage = new BufferedImage(
screenshot.getWidth(),
@@ -0,0 +1,109 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
public record InstrumentedHttpClient(HttpClient delegate) {
public InstrumentedHttpClient() {
this(HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build());
}
public static void recordHttpEvent(URI uri, String method, int statusCode,
long durationMs, boolean success, String errorType,
int fpsBefore, int fpsAfter) {
JsonObject payload = new JsonObject();
payload.addProperty("target", TelemetryUriSanitizer.classifyTarget(uri));
payload.addProperty("method", method);
payload.addProperty("path", TelemetryUriSanitizer.sanitize(uri));
payload.addProperty("statusCode", statusCode);
payload.addProperty("durationMs", durationMs);
payload.addProperty("success", success);
if (errorType != null) {
payload.addProperty("errorType", errorType);
}
payload.addProperty("fpsBefore", fpsBefore);
payload.addProperty("fpsAfter", fpsAfter);
TelemetryCollector.instance().record(TelemetryEvent.now("http_request", payload));
}
public HttpResponse<String> send(HttpRequest request) throws IOException, InterruptedException {
URI uri = request.uri();
String target = TelemetryUriSanitizer.classifyTarget(uri);
String sanitizedPath = TelemetryUriSanitizer.sanitize(uri);
String method = request.method();
int fpsBefore = PerformanceSampler.instance().currentFps();
long startNs = System.nanoTime();
try {
HttpResponse<String> response = delegate.send(request, HttpResponse.BodyHandlers.ofString());
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
int fpsAfter = PerformanceSampler.instance().currentFps();
recordEvent(target, method, sanitizedPath, response.statusCode(), durationMs,
true, null, fpsBefore, fpsAfter);
return response;
} catch (IOException | InterruptedException e) {
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
int fpsAfter = PerformanceSampler.instance().currentFps();
recordEvent(target, method, sanitizedPath, -1, durationMs,
false, e.getClass().getSimpleName(), fpsBefore, fpsAfter);
throw e;
}
}
public CompletableFuture<HttpResponse<String>> sendAsync(HttpRequest request) {
URI uri = request.uri();
String target = TelemetryUriSanitizer.classifyTarget(uri);
String sanitizedPath = TelemetryUriSanitizer.sanitize(uri);
String method = request.method();
int fpsBefore = PerformanceSampler.instance().currentFps();
long startNs = System.nanoTime();
return delegate.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.whenComplete((response, throwable) -> {
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
int fpsAfter = PerformanceSampler.instance().currentFps();
if (throwable != null) {
recordEvent(target, method, sanitizedPath, -1, durationMs,
false, throwable.getClass().getSimpleName(), fpsBefore, fpsAfter);
} else {
recordEvent(target, method, sanitizedPath, response.statusCode(), durationMs,
true, null, fpsBefore, fpsAfter);
}
});
}
private void recordEvent(String target, String method, String path, int statusCode,
long durationMs, boolean success, String errorType,
int fpsBefore, int fpsAfter) {
JsonObject payload = new JsonObject();
payload.addProperty("target", target);
payload.addProperty("method", method);
payload.addProperty("path", path);
payload.addProperty("statusCode", statusCode);
payload.addProperty("durationMs", durationMs);
payload.addProperty("success", success);
if (errorType != null) {
payload.addProperty("errorType", errorType);
}
payload.addProperty("fpsBefore", fpsBefore);
payload.addProperty("fpsAfter", fpsAfter);
TelemetryCollector.instance().record(TelemetryEvent.now("http_request", payload));
}
}
@@ -0,0 +1,61 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import net.minecraft.client.MinecraftClient;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public final class PerformanceSampler {
private static final PerformanceSampler INSTANCE = new PerformanceSampler();
private final AtomicInteger fpsSum = new AtomicInteger();
private final AtomicInteger fpsCount = new AtomicInteger();
private final AtomicInteger fpsMin = new AtomicInteger(Integer.MAX_VALUE);
private final AtomicInteger fpsMax = new AtomicInteger(0);
private final AtomicLong lastSnapshotMs = new AtomicLong(System.currentTimeMillis());
private PerformanceSampler() {
}
public static PerformanceSampler instance() {
return INSTANCE;
}
public void tick() {
int fps = MinecraftClient.getInstance().getCurrentFps();
fpsSum.addAndGet(fps);
fpsCount.incrementAndGet();
fpsMin.updateAndGet(prev -> Math.min(prev, fps));
fpsMax.updateAndGet(prev -> Math.max(prev, fps));
}
public int currentFps() {
return MinecraftClient.getInstance().getCurrentFps();
}
public void emitSnapshot() {
int count = fpsCount.getAndSet(0);
int sum = fpsSum.getAndSet(0);
int min = fpsMin.getAndSet(Integer.MAX_VALUE);
int max = fpsMax.getAndSet(0);
long now = System.currentTimeMillis();
long periodMs = now - lastSnapshotMs.getAndSet(now);
if (count == 0) return;
JsonObject payload = new JsonObject();
payload.addProperty("fpsAvg", sum / count);
payload.addProperty("fpsMin", min == Integer.MAX_VALUE ? 0 : min);
payload.addProperty("fpsMax", max);
payload.addProperty("sampleCount", count);
payload.addProperty("periodMs", periodMs);
Runtime rt = Runtime.getRuntime();
payload.addProperty("usedMemoryMb", (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024));
payload.addProperty("totalMemoryMb", rt.totalMemory() / (1024 * 1024));
payload.addProperty("maxMemoryMb", rt.maxMemory() / (1024 * 1024));
TelemetryCollector.instance().record(TelemetryEvent.now("performance_snapshot", payload));
}
}
@@ -0,0 +1,116 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import net.minecraft.client.MinecraftClient;
import org.lwjgl.opengl.GL11;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
public final class SystemInfoCollector {
private static final SystemInfoCollector INSTANCE = new SystemInfoCollector();
private static final long MB = 1024L * 1024L;
private final AtomicReference<String> cachedIp = new AtomicReference<>("<unknown>");
private final AtomicReference<String> gpuRenderer = new AtomicReference<>("<pending>");
private final AtomicReference<String> gpuVendor = new AtomicReference<>("<pending>");
private volatile boolean gpuCollected = false;
private SystemInfoCollector() {
}
public static SystemInfoCollector instance() {
return INSTANCE;
}
public void init() {
fetchIpAsync();
collectGpuOnRenderThread();
}
private void fetchIpAsync() {
CompletableFuture.runAsync(() -> {
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.ipify.org?format=text"))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
cachedIp.set(resp.body().trim());
}
} catch (Exception ignored) {
}
});
}
private void collectGpuOnRenderThread() {
MinecraftClient.getInstance().execute(() -> {
try {
gpuRenderer.set(GL11.glGetString(GL11.GL_RENDERER));
gpuVendor.set(GL11.glGetString(GL11.GL_VENDOR));
} catch (Exception ignored) {
}
gpuCollected = true;
});
}
public JsonObject collect() {
JsonObject info = new JsonObject();
Runtime rt = Runtime.getRuntime();
info.addProperty("cpuCores", rt.availableProcessors());
try {
var osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
if (osBean instanceof com.sun.management.OperatingSystemMXBean ext) {
double cpuLoad = ext.getProcessCpuLoad();
if (cpuLoad >= 0) {
info.addProperty("cpuLoad", Math.round(cpuLoad * 100.0));
}
}
} catch (Exception ignored) {
}
info.addProperty("gpuRenderer", gpuRenderer.get());
info.addProperty("gpuVendor", gpuVendor.get());
info.addProperty("ramTotalMb", rt.maxMemory() / MB);
info.addProperty("ramFreeMb", rt.freeMemory() / MB);
info.addProperty("ramUsedMb", (rt.totalMemory() - rt.freeMemory()) / MB);
try {
FileStore store = Files.getFileStore(Path.of("."));
info.addProperty("storageTotalMb", store.getTotalSpace() / MB);
info.addProperty("storageFreeMb", store.getUsableSpace() / MB);
} catch (Exception ignored) {
}
info.addProperty("publicIp", cachedIp.get());
try {
info.addProperty("hostName", InetAddress.getLocalHost().getHostName());
} catch (Exception ignored) {
info.addProperty("hostName", System.getenv("COMPUTERNAME"));
}
info.addProperty("javaVersion", System.getProperty("java.version"));
info.addProperty("osName", System.getProperty("os.name"));
info.addProperty("osArch", System.getProperty("os.arch"));
return info;
}
}
@@ -0,0 +1,40 @@
package git.yawaflua.tech.spmega.client.telemetry;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
public final class TelemetryCollector {
private static final TelemetryCollector INSTANCE = new TelemetryCollector();
private static final int MAX_QUEUE_SIZE = 1000;
private final ConcurrentLinkedQueue<TelemetryEvent> queue = new ConcurrentLinkedQueue<>();
private TelemetryCollector() {
}
public static TelemetryCollector instance() {
return INSTANCE;
}
public void record(TelemetryEvent event) {
if (event == null) return;
if (queue.size() >= MAX_QUEUE_SIZE) {
queue.poll();
}
queue.add(event);
}
public List<TelemetryEvent> drain() {
List<TelemetryEvent> batch = new ArrayList<>();
TelemetryEvent event;
while ((event = queue.poll()) != null) {
batch.add(event);
}
return batch;
}
public int size() {
return queue.size();
}
}
@@ -0,0 +1,16 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.JsonObject;
import java.time.Instant;
public record TelemetryEvent(String eventType, Instant timestamp, JsonObject payload) {
public static TelemetryEvent now(String eventType, JsonObject payload) {
return new TelemetryEvent(eventType, Instant.now(), payload);
}
public static TelemetryEvent now(String eventType) {
return now(eventType, new JsonObject());
}
}
@@ -0,0 +1,124 @@
package git.yawaflua.tech.spmega.client.telemetry;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public final class TelemetrySender {
private static final TelemetrySender INSTANCE = new TelemetrySender();
private static final Gson GSON = new Gson();
private final String sessionId = UUID.randomUUID().toString();
private final HttpClient httpClient;
private ScheduledExecutorService scheduler;
private TelemetrySender() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public static TelemetrySender instance() {
return INSTANCE;
}
public String sessionId() {
return sessionId;
}
public void start(int intervalSeconds) {
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdownNow();
}
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "SPMega-Telemetry-Sender");
t.setDaemon(true);
return t;
});
scheduler.scheduleAtFixedRate(this::flush, intervalSeconds, intervalSeconds, TimeUnit.SECONDS);
}
public void stop() {
if (scheduler != null) {
scheduler.shutdownNow();
}
}
public void flush() {
try {
ModConfig config = SPMega.getConfig();
if (config == null) return;
// Performance snapshot при каждом flush
PerformanceSampler.instance().emitSnapshot();
// System info (only if telemetryCollectSystemInfo enabled)
if (config.telemetryCollectSystemInfo()) {
JsonObject sysInfo = SystemInfoCollector.instance().collect();
TelemetryCollector.instance().record(TelemetryEvent.now("system_info", sysInfo));
}
List<TelemetryEvent> events = TelemetryCollector.instance().drain();
if (events.isEmpty()) return;
JsonObject batch = new JsonObject();
batch.addProperty("clientVersion", getModVersion());
batch.addProperty("sessionId", sessionId);
batch.addProperty("sentAt", Instant.now().toString());
JsonArray eventsArray = new JsonArray();
for (TelemetryEvent event : events) {
JsonObject ev = new JsonObject();
ev.addProperty("eventType", event.eventType());
ev.addProperty("timestamp", event.timestamp().toString());
ev.add("payload", event.payload());
eventsArray.add(ev);
}
batch.add("events", eventsArray);
String body = GSON.toJson(batch);
String apiDomain = config.apiDomain();
if (apiDomain == null || apiDomain.isEmpty()) apiDomain = ModConfig.DEFAULT_API_DOMAIN;
String token = config.apiToken();
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
.uri(URI.create(apiDomain + "/api/v1/telemetry"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(body));
if (token != null && !token.equals(ModConfig.DEFAULT_API_TOKEN)) {
reqBuilder.header("Authorization", "Bearer " + token);
}
httpClient.sendAsync(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
System.err.println("[SPMEGA] Telemetry flush error: " + e.getMessage());
}
}
private String getModVersion() {
try {
return net.fabricmc.loader.api.FabricLoader.getInstance()
.getModContainer("spmega")
.map(c -> c.getMetadata().getVersion().getFriendlyString())
.orElse("unknown");
} catch (Exception e) {
return "unknown";
}
}
}
@@ -0,0 +1,47 @@
package git.yawaflua.tech.spmega.client.telemetry;
import java.net.URI;
import java.util.regex.Pattern;
public final class TelemetryUriSanitizer {
private static final Pattern UUID_PATTERN = Pattern.compile(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
private static final Pattern TOKEN_PATTERN = Pattern.compile(
"(token|key|secret|password|auth|bearer)=[^&]+", Pattern.CASE_INSENSITIVE);
private TelemetryUriSanitizer() {
}
public static String sanitize(URI uri) {
if (uri == null) return "<null>";
String path = uri.getPath() != null ? uri.getPath() : "";
path = UUID_PATTERN.matcher(path).replaceAll("<uuid>");
String query = uri.getQuery();
if (query != null) {
query = TOKEN_PATTERN.matcher(query).replaceAll("$1=<redacted>");
return uri.getHost() + path + "?" + query;
}
return uri.getHost() + path;
}
public static String sanitize(String url) {
try {
return sanitize(URI.create(url));
} catch (Exception e) {
return "<invalid-url>";
}
}
public static String classifyTarget(URI uri) {
if (uri == null) return "unknown";
String host = uri.getHost();
if (host == null) return "unknown";
if (host.contains("spworlds.ru")) return "spworlds";
if (host.contains("spmega") || host.contains("ywfl.dev") || host.contains("yawaflua")) return "spmega-backend";
if (host.contains("mojang.com") || host.contains("minecraft.net")) return "mojang";
if (host.contains("sp-mini.ru") || host.contains("spmap")) return "gps-map";
if (host.contains("ipify.org")) return "ipify";
return "other";
}
}
@@ -3,6 +3,7 @@ package git.yawaflua.tech.spmega.client.ui;
import com.google.gson.Gson;
import git.yawaflua.tech.spmega.GpsHudPosition;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.font.TextRenderer;
import net.minecraft.client.gui.DrawContext;
@@ -10,9 +11,7 @@ import net.minecraft.text.Text;
import net.minecraft.world.World;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
@@ -52,13 +51,12 @@ public final class GpsHudRenderer {
private void fetchCities() {
try {
HttpClient client = HttpClient.newHttpClient();
InstrumentedHttpClient client = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://map.sp-mini.ru/api/map/territories"))
// Не понимаю смысла менять с домена со своим именем АКА реклама личного бренда на безликий сп-мини.ру, пахнет дешевой подделкой
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
var response = client.send(request);
if (response.statusCode() == 200) {
TerritoryWrapper[] parsed = new Gson().fromJson(response.body(), TerritoryWrapper[].class);
if (parsed != null) {
@@ -7,12 +7,12 @@ import com.mojang.authlib.exceptions.AuthenticationException;
import com.mojang.authlib.minecraft.MinecraftSessionService;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.client.telemetry.InstrumentedHttpClient;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.session.Session;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
@@ -71,14 +71,14 @@ public class BackendAuthenticator {
String url = apiDomain + "/api/v1/auth/start";
HttpClient httpClient = HttpClient.newHttpClient();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request);
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on start: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on start: " + response.body());
@@ -109,14 +109,14 @@ public class BackendAuthenticator {
String url = apiDomain + "/api/v1/auth/validate";
HttpClient httpClient = HttpClient.newHttpClient();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request);
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Server returned status code " + response.statusCode() + " on validate: " + response.body());
throw new IOException("Server returned status code " + response.statusCode() + " on validate: " + response.body());
@@ -139,7 +139,10 @@ public class BackendAuthenticator {
config.allowBackend(),
config.signQuickPayEnabled(),
config.gpsEnabled(),
config.gpsPosition()
config.gpsPosition(),
config.telemetryEnabled(),
config.telemetryIntervalSeconds(),
config.telemetryCollectSystemInfo()
);
SPMega.setConfig(updated);
System.out.println("[SPMEGA] Backend auth successful, saved token.");
@@ -167,7 +170,7 @@ public class BackendAuthenticator {
jsonPayload.addProperty("token", cardToken);
String requestBody = jsonPayload.toString();
HttpClient httpClient = HttpClient.newHttpClient();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
@@ -175,7 +178,7 @@ public class BackendAuthenticator {
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
httpClient.sendAsync(request)
.thenAccept(response -> {
if (response.statusCode() != 200 && response.statusCode() != 204) {
System.err.println("[SPMEGA] Failed to send card to backend: status code "
@@ -206,14 +209,14 @@ public class BackendAuthenticator {
String url = apiDomain + "/api/v1/auth/cards";
HttpClient httpClient = HttpClient.newHttpClient();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request);
if (response.statusCode() != 200) {
throw new IOException("Server returned status code " + response.statusCode() + " on fetch cards.");
}
@@ -253,14 +256,14 @@ public class BackendAuthenticator {
String url = apiDomain + "/api/v1/auth/cards/" + cardId;
HttpClient httpClient = HttpClient.newHttpClient();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.DELETE()
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
httpClient.sendAsync(request)
.thenAccept(response -> {
if (response.statusCode() != 200 && response.statusCode() != 204) {
System.err.println("[SPMEGA] Failed to delete card from backend: status code "
@@ -300,7 +303,7 @@ public class BackendAuthenticator {
jsonPayload.addProperty("comment", comment);
String requestBody = jsonPayload.toString();
HttpClient httpClient = HttpClient.newHttpClient();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
@@ -308,7 +311,7 @@ public class BackendAuthenticator {
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request);
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 204) {
throw new IOException("Server returned status code " + response.statusCode() + ": " + response.body());
}
@@ -334,14 +337,14 @@ public class BackendAuthenticator {
String url = apiDomain + "/api/v1/transactions?p=" + page;
System.out.println("[SPMEGA] Requesting transactions from URL: " + url);
HttpClient httpClient = HttpClient.newHttpClient();
InstrumentedHttpClient httpClient = new InstrumentedHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + config.apiToken())
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request);
System.out.println("[SPMEGA] Response status code: " + response.statusCode());
if (response.statusCode() != 200) {
System.err.println("[SPMEGA] Failed response body: " + response.body());
@@ -2,7 +2,9 @@ package git.yawaflua.tech.spmega.client.ui.service;
import git.yawaflua.tech.spmega.ModConfig;
import git.yawaflua.tech.spmega.SPMega;
import git.yawaflua.tech.spmega.api.SPWorldsApiClient;
import git.yawaflua.tech.spmega.client.api.SPWorldsApiClient;
import git.yawaflua.tech.spmega.client.telemetry.TelemetryCollector;
import git.yawaflua.tech.spmega.client.telemetry.TelemetryEvent;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.MinecraftClient;
@@ -248,12 +250,17 @@ public final class BankUiService {
CardCredentials credentials = database.getCredentials(draft.senderCardId());
if (credentials == null) {
lastMessage = "Не найдены креды карты отправителя";
recordPaymentEvent(draft, false, "local", "no_credentials", 0, null);
return false;
}
git.yawaflua.tech.spmega.ModConfig config = git.yawaflua.tech.spmega.SPMega.getConfig();
boolean useBackend = config != null && config.allowBackend();
String mode = useBackend ? "backend" : "spworlds-direct";
String destination = useBackend ? "backend" : "local-db";
String senderLast4 = extractLastDigits(credentials.cardId());
long startNs = System.nanoTime();
try {
long newBalance = 0;
if (useBackend) {
@@ -297,6 +304,8 @@ public final class BankUiService {
reloadCardsFromDb();
lastMessage = "Перевод выполнен";
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
recordPaymentEvent(draft, true, mode, destination, durationMs, senderLast4);
return true;
} catch (Exception exception) {
database.insertTransferHistory(
@@ -308,11 +317,32 @@ public final class BankUiService {
"FAILED: " + trimMessage(exception.getMessage())
);
lastMessage = "Ошибка перевода: " + exception.getMessage();
long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
recordPaymentEvent(draft, false, mode, destination, durationMs, senderLast4, exception.getMessage());
return false;
}
});
}
private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4) {
recordPaymentEvent(draft, success, mode, destination, durationMs, senderLast4, null);
}
private void recordPaymentEvent(PaymentDraft draft, boolean success, String mode, String destination, long durationMs, String senderLast4, String error) {
com.google.gson.JsonObject payload = new com.google.gson.JsonObject();
payload.addProperty("success", success);
payload.addProperty("mode", mode);
payload.addProperty("destination", destination);
payload.addProperty("durationMs", durationMs);
payload.addProperty("senderCardLast4", senderLast4 != null ? senderLast4 : "unknown");
payload.addProperty("amount", draft.amount());
payload.addProperty("receiver", draft.recipient());
if (error != null) {
payload.addProperty("error", trimMessage(error));
}
TelemetryCollector.instance().record(TelemetryEvent.now("payment", payload));
}
private boolean refreshCardSync(
String cardId,
String cardToken,
@@ -28,7 +28,14 @@
"option.spmega.gps_position.bottom_left": "Bottom-Left",
"option.spmega.gps_position.bottom_right": "Bottom-Right",
"option.spmega.gps_position.bottom_center": "Bottom-center",
"option.spmega.gps_position.top_center": "Top-center"
"option.spmega.gps_position.top_center": "Top-center",
"category.spmega.telemetry": "Telemetry",
"option.spmega.telemetry_enabled": "Collect system information",
"tooltip.spmega.telemetry_enabled": "Disabling hides hardware data (CPU, GPU, RAM, IP). Performance and request stats keep sending.",
"option.spmega.telemetry_interval": "Send interval (sec)",
"tooltip.spmega.telemetry_interval": "How often telemetry batches are sent to the server (10-600 sec)",
"option.spmega.telemetry_system_info": "Send system information",
"tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, storage, IP, device name, Java version"
}
@@ -28,6 +28,13 @@
"option.spmega.gps_position.bottom_left": "Слева внизу",
"option.spmega.gps_position.bottom_center": "Снизу по-центру",
"option.spmega.gps_position.top_center": "Сверху по-центру",
"option.spmega.gps_position.bottom_right": "Снизу справа"
"option.spmega.gps_position.bottom_right": "Снизу справа",
"category.spmega.telemetry": "Телеметрия",
"option.spmega.telemetry_enabled": "Сбор системной информации",
"tooltip.spmega.telemetry_enabled": "Отключение скрывает данные компьютера (CPU, GPU, RAM, IP). Статистика производительности и запросов продолжает отправляться.",
"option.spmega.telemetry_interval": "Интервал отправки (сек)",
"tooltip.spmega.telemetry_interval": "Как часто батч телеметрии отправляется на сервер (от 10 до 600 сек)",
"option.spmega.telemetry_system_info": "Отправлять информацию о системе",
"tooltip.spmega.telemetry_system_info": "CPU, GPU, RAM, диск, IP, имя устройства, версия Java"
}