From 0e53d7dd8c89816feb279415226c3a86c75f4334 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Sat, 11 Jul 2026 00:38:28 +0300 Subject: [PATCH] Implement telemetry system with data collection and reporting Signed-off-by: Dmitrii Took 20 minutes --- .gitignore | 3 +- .../Controllers/v1/AuthController.cs | 7 +- .../Controllers/v1/TelemetryController.cs | 45 +++ .../Middleware/RequestTelemetryMiddleware.cs | 50 +++ .../Persistent/Database/AppDbContext.cs | 3 + .../Models/DTO/ModTelemetryBatchDto.cs | 16 + .../BackendRequestTelemetryDocument.cs | 19 ++ .../Models/Telemetry/ModTelemetryDocument.cs | 21 ++ backend/SpMega.Backend/Program.cs | 24 +- backend/SpMega.Backend/SpMega.Backend.csproj | 3 + .../Debug/net10.0/SpMega.Backend.deps.json | 317 ++++++++++++++++++ .../net10.0/SpMega.Backend.runtimeconfig.json | 20 ++ ...ega.Backend.staticwebassets.endpoints.json | 5 + backend/backend.sln.DotSettings.user | 3 + .../spmega/client/ModMenuIntegration.java | 32 +- .../tech/spmega/client/SPMegaClient.java | 28 +- .../spmega/client}/api/SPWorldsApiClient.java | 10 +- .../tech/spmega/client/qr/QRCodeScanner.java | 22 ++ .../telemetry/InstrumentedHttpClient.java | 109 ++++++ .../client/telemetry/PerformanceSampler.java | 61 ++++ .../client/telemetry/SystemInfoCollector.java | 116 +++++++ .../client/telemetry/TelemetryCollector.java | 40 +++ .../client/telemetry/TelemetryEvent.java | 16 + .../client/telemetry/TelemetrySender.java | 124 +++++++ .../telemetry/TelemetryUriSanitizer.java | 47 +++ .../tech/spmega/client/ui/GpsHudRenderer.java | 8 +- .../ui/service/BackendAuthenticator.java | 35 +- .../client/ui/service/BankUiService.java | 32 +- .../resources/assets/spmega/lang/en_us.json | 9 +- .../resources/assets/spmega/lang/ru_ru.json | 9 +- .../yawaflua/tech/spmega/ConfigManager.java | 36 +- .../git/yawaflua/tech/spmega/ModConfig.java | 13 +- .../java/git/yawaflua/tech/spmega/SPMega.java | 3 + 33 files changed, 1245 insertions(+), 41 deletions(-) create mode 100644 backend/SpMega.Backend/Controllers/v1/TelemetryController.cs create mode 100644 backend/SpMega.Backend/Middleware/RequestTelemetryMiddleware.cs create mode 100644 backend/SpMega.Backend/Persistent/Models/DTO/ModTelemetryBatchDto.cs create mode 100644 backend/SpMega.Backend/Persistent/Models/Telemetry/BackendRequestTelemetryDocument.cs create mode 100644 backend/SpMega.Backend/Persistent/Models/Telemetry/ModTelemetryDocument.cs create mode 100644 backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.deps.json create mode 100644 backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.runtimeconfig.json create mode 100644 backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.staticwebassets.endpoints.json create mode 100644 backend/backend.sln.DotSettings.user rename src/{main/java/git/yawaflua/tech/spmega => client/java/git/yawaflua/tech/spmega/client}/api/SPWorldsApiClient.java (96%) create mode 100644 src/client/java/git/yawaflua/tech/spmega/client/telemetry/InstrumentedHttpClient.java create mode 100644 src/client/java/git/yawaflua/tech/spmega/client/telemetry/PerformanceSampler.java create mode 100644 src/client/java/git/yawaflua/tech/spmega/client/telemetry/SystemInfoCollector.java create mode 100644 src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryCollector.java create mode 100644 src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryEvent.java create mode 100644 src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetrySender.java create mode 100644 src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryUriSanitizer.java diff --git a/.gitignore b/.gitignore index 4f5fbb6..7f98df3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,8 @@ hs_err_pid* # Common working directory run -backend/SpMega.Backend/src/* +backend/SpMega.Backend/bin/* backend/SpMega.Backend/obj/* backend/SpMega.Backend/appsettings.json +backend/SpMega.Backend/appsettings.Development.json backend/SpMega.Backend/SpMega.Backend.http \ No newline at end of file diff --git a/backend/SpMega.Backend/Controllers/v1/AuthController.cs b/backend/SpMega.Backend/Controllers/v1/AuthController.cs index 2a3ba97..6d9b7b0 100644 --- a/backend/SpMega.Backend/Controllers/v1/AuthController.cs +++ b/backend/SpMega.Backend/Controllers/v1/AuthController.cs @@ -20,6 +20,7 @@ namespace SpMega.Backend.Controllers.v1; public record GetSessionIdBody(string userName, Guid userUUID); public record ValidateSessionBody(string sessionId, Guid userUUID); + [Route("api/v1/auth")] [ApiController] public class AuthController(AppDbContext dbContext, TokenService tokenService, ILogger logger) : ControllerBase @@ -67,7 +68,7 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I var resp = await httpClient.SendAsync(request); if (resp.StatusCode != HttpStatusCode.OK) throw new Exception("Mojang response is not OK"); - Console.WriteLine(await resp.Content.ReadAsStringAsync()); + var dto = await resp.Content.ReadFromJsonAsync(); if (dto == null || dto.Name != session.UserName || Guid.Parse(dto.Id) != session.UserId) throw new Exception("Session expired, or dto is not acceptable."); var token = tokenService.GenerateAccessToken(session.UserName, body.userUUID); @@ -75,6 +76,9 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I if (user != null) { user.Token = token; + user.Username = session.UserName; + user.UpdatedAt = DateTime.UtcNow; + dbContext.Update(user); } else @@ -84,6 +88,7 @@ public class AuthController(AppDbContext dbContext, TokenService tokenService, I Id = body.userUUID, Username = session.UserName, Token = token, + UpdatedAt = DateTime.UtcNow }; await dbContext.AddAsync(user); } diff --git a/backend/SpMega.Backend/Controllers/v1/TelemetryController.cs b/backend/SpMega.Backend/Controllers/v1/TelemetryController.cs new file mode 100644 index 0000000..74d4f0c --- /dev/null +++ b/backend/SpMega.Backend/Controllers/v1/TelemetryController.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SpMega.Backend.Persistent.Models.DTO; +using SpMega.Backend.Persistent.Models.Users; + +namespace SpMega.Backend.Controllers.v1; + +[ApiController] +[Route("api/v1/telemetry")] +public class TelemetryController : ControllerBase +{ + private static readonly ActivitySource Source = new("SpMega.ModTelemetry"); + + [HttpPost] + [Authorize] + public IActionResult Post([FromBody] ModTelemetryBatchDto batch) + { + if (batch?.Events == null || batch.Events.Count == 0) + { + return Ok(new { received = 0 }); + } + + var user = HttpContext.Items["@me"] as User; + var userId = user?.Id.ToString() ?? User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "anonymous"; + var sessionId = !string.IsNullOrEmpty(batch.SessionId) ? batch.SessionId : Guid.NewGuid().ToString("N"); + + foreach (var e in batch.Events) + { + using var activity = Source.StartActivity("mod.telemetry.event", ActivityKind.Internal); + if (activity is null) continue; + + activity.SetTag("user.id", userId); + activity.SetTag("session.id", sessionId); + activity.SetTag("client.version", batch.ClientVersion ?? string.Empty); + activity.SetTag("event.type", e.EventType); + activity.SetTag("event.timestamp", e.Timestamp.ToString("O")); + activity.SetTag("event.payload", JsonSerializer.Serialize(e.Payload)); + activity.SetTag("batch.sent_at", batch.SentAt.ToString("O")); + } + + return Ok(new { received = batch.Events.Count }); + } +} diff --git a/backend/SpMega.Backend/Middleware/RequestTelemetryMiddleware.cs b/backend/SpMega.Backend/Middleware/RequestTelemetryMiddleware.cs new file mode 100644 index 0000000..ec1917b --- /dev/null +++ b/backend/SpMega.Backend/Middleware/RequestTelemetryMiddleware.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; + +namespace SpMega.Backend.Middleware; + +public class RequestTelemetryMiddleware(RequestDelegate next, ILogger logger) +{ + private static readonly ActivitySource Source = new("SpMega.RequestTelemetry"); + private const string RequestIdHeader = "X-Request-Id"; + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value ?? string.Empty; + if (path.Equals("/api/v1/telemetry", StringComparison.OrdinalIgnoreCase) + && context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase)) + { + await next(context); + return; + } + + var requestId = context.Request.Headers[RequestIdHeader].FirstOrDefault() ?? Guid.NewGuid().ToString("N"); + context.Response.Headers[RequestIdHeader] = requestId; + + using var activity = Source.StartActivity("http.request", ActivityKind.Server); + activity?.SetTag("request.id", requestId); + activity?.SetTag("http.method", context.Request.Method); + activity?.SetTag("http.path", path); + activity?.SetTag("http.scheme", context.Request.Scheme); + activity?.SetTag("http.host", context.Request.Host.Value ?? string.Empty); + activity?.SetTag("client.ip", context.Connection.RemoteIpAddress?.ToString()); + activity?.SetTag("http.user_agent", context.Request.Headers.UserAgent.ToString()); + + var stopwatch = Stopwatch.StartNew(); + try + { + await next(context); + } + finally + { + stopwatch.Stop(); + activity?.SetTag("http.status_code", context.Response.StatusCode); + activity?.SetTag("http.duration_ms", stopwatch.ElapsedMilliseconds); + + var userId = context.User?.Identity?.IsAuthenticated == true + ? context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value + : null; + if (userId is not null) + activity?.SetTag("user.id", userId); + } + } +} diff --git a/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs b/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs index c56b410..ba6c220 100644 --- a/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs +++ b/backend/SpMega.Backend/Persistent/Database/AppDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using MongoDB.EntityFrameworkCore.Extensions; +using SpMega.Backend.Persistent.Models.Telemetry; using SpMega.Backend.Persistent.Models.Transactions; using SpMega.Backend.Persistent.Models.Users; using SpMega.Backend.Services; @@ -11,6 +12,8 @@ public class AppDbContext(DbContextOptions options) : DbContext(op internal DbSet Transactions { get; set; } internal DbSet Users { get; set; } internal DbSet UserSessions { get; set; } + internal DbSet ModTelemetry { get; set; } + internal DbSet BackendRequestTelemetry { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/backend/SpMega.Backend/Persistent/Models/DTO/ModTelemetryBatchDto.cs b/backend/SpMega.Backend/Persistent/Models/DTO/ModTelemetryBatchDto.cs new file mode 100644 index 0000000..4f1dddd --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/DTO/ModTelemetryBatchDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json; + +namespace SpMega.Backend.Persistent.Models.DTO; + +public record ModTelemetryEventDto( + string EventType, + DateTimeOffset Timestamp, + JsonElement Payload +); + +public record ModTelemetryBatchDto( + string ClientVersion, + string SessionId, + DateTimeOffset SentAt, + List Events +); diff --git a/backend/SpMega.Backend/Persistent/Models/Telemetry/BackendRequestTelemetryDocument.cs b/backend/SpMega.Backend/Persistent/Models/Telemetry/BackendRequestTelemetryDocument.cs new file mode 100644 index 0000000..b3b1712 --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/Telemetry/BackendRequestTelemetryDocument.cs @@ -0,0 +1,19 @@ +using MongoDB.Bson; + +namespace SpMega.Backend.Persistent.Models.Telemetry; + +public class BackendRequestTelemetryDocument +{ + public ObjectId Id { get; set; } + public string RequestId { get; set; } = string.Empty; + public string Method { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string Scheme { get; set; } = string.Empty; + public string Host { get; set; } = string.Empty; + public string? ClientIp { get; set; } + public string? UserAgent { get; set; } + public int StatusCode { get; set; } + public long DurationMs { get; set; } + public string? UserId { get; set; } + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/backend/SpMega.Backend/Persistent/Models/Telemetry/ModTelemetryDocument.cs b/backend/SpMega.Backend/Persistent/Models/Telemetry/ModTelemetryDocument.cs new file mode 100644 index 0000000..dacddd6 --- /dev/null +++ b/backend/SpMega.Backend/Persistent/Models/Telemetry/ModTelemetryDocument.cs @@ -0,0 +1,21 @@ +using MongoDB.Bson; + +namespace SpMega.Backend.Persistent.Models.Telemetry; + +public class ModTelemetryDocument +{ + public ObjectId Id { get; set; } + public string UserId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + public string ClientVersion { get; set; } = string.Empty; + public DateTimeOffset SentAt { get; set; } + public DateTimeOffset ReceivedAt { get; set; } = DateTimeOffset.UtcNow; + public List Events { get; set; } = []; +} + +public class ModTelemetryEventDocument +{ + public string EventType { get; set; } = string.Empty; + public DateTimeOffset Timestamp { get; set; } + public string PayloadJson { get; set; } = "{}"; +} diff --git a/backend/SpMega.Backend/Program.cs b/backend/SpMega.Backend/Program.cs index 53af444..d292eb7 100644 --- a/backend/SpMega.Backend/Program.cs +++ b/backend/SpMega.Backend/Program.cs @@ -3,7 +3,10 @@ using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; using SpMega.Backend.Authetication; +using SpMega.Backend.Middleware; using SpMega.Backend.Persistent.Database; using SpMega.Backend.Services; @@ -56,18 +59,33 @@ public class Program .RequireAuthenticatedUser() .Build()); - var serviceCollection = new ServiceCollection(); var mongoClient = new MongoClient(builder.Configuration.GetValue("Mongo") ?? "mongodb://curiosity:27018"); - serviceCollection.AddSingleton(mongoClient); - + builder.Services.AddSingleton(mongoClient); + builder.Services.AddDbContext((_, options) => { options.UseMongoDB(mongoClient, "spmega"); }); + + var otlpEndpoint = conf["Otlp:Endpoint"] ?? "http://localhost:4317"; + builder.Services.AddOpenTelemetry() + .ConfigureResource(res => res.AddService("SpMega.Backend")) + .WithTracing(tracing => + { + tracing + .AddSource("SpMega.ModTelemetry") + .AddSource("SpMega.RequestTelemetry") + .AddAspNetCoreInstrumentation() + .AddOtlpExporter(opts => + { + opts.Endpoint = new Uri(otlpEndpoint); + }); + }); var app = builder.Build(); + app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), appBuilder => diff --git a/backend/SpMega.Backend/SpMega.Backend.csproj b/backend/SpMega.Backend/SpMega.Backend.csproj index ec99eea..6b58d4c 100644 --- a/backend/SpMega.Backend/SpMega.Backend.csproj +++ b/backend/SpMega.Backend/SpMega.Backend.csproj @@ -22,6 +22,9 @@ + + + diff --git a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.deps.json b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.deps.json new file mode 100644 index 0000000..5d32a6c --- /dev/null +++ b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.deps.json @@ -0,0 +1,317 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v10.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v10.0": { + "SpMega.Backend/1.0.0": { + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.9", + "Microsoft.EntityFrameworkCore.Relational": "10.0.9", + "Microsoft.IdentityModel.Tokens": "8.19.1", + "MongoDB.EntityFrameworkCore": "10.0.2", + "System.IdentityModel.Tokens.Jwt": "8.19.1", + "spworlds-api": "1.0.3" + }, + "runtime": { + "SpMega.Backend.dll": {} + } + }, + "DnsClient/1.6.1": { + "runtime": { + "lib/net5.0/DnsClient.dll": { + "assemblyVersion": "1.6.1.0", + "fileVersion": "1.6.1.0" + } + } + }, + "Microsoft.Bcl.Cryptography/10.0.2": { + "runtime": { + "lib/net10.0/Microsoft.Bcl.Cryptography.dll": { + "assemblyVersion": "10.0.0.2", + "fileVersion": "10.0.225.61305" + } + } + }, + "Microsoft.EntityFrameworkCore/10.0.9": { + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.9" + }, + "runtime": { + "lib/net10.0/Microsoft.EntityFrameworkCore.dll": { + "assemblyVersion": "10.0.9.0", + "fileVersion": "10.0.926.27113" + } + } + }, + "Microsoft.EntityFrameworkCore.Abstractions/10.0.9": { + "runtime": { + "lib/net10.0/Microsoft.EntityFrameworkCore.Abstractions.dll": { + "assemblyVersion": "10.0.9.0", + "fileVersion": "10.0.926.27113" + } + } + }, + "Microsoft.EntityFrameworkCore.Relational/10.0.9": { + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.9" + }, + "runtime": { + "lib/net10.0/Microsoft.EntityFrameworkCore.Relational.dll": { + "assemblyVersion": "10.0.9.0", + "fileVersion": "10.0.926.27113" + } + } + }, + "Microsoft.IdentityModel.Abstractions/8.19.1": { + "runtime": { + "lib/net10.0/Microsoft.IdentityModel.Abstractions.dll": { + "assemblyVersion": "8.19.1.0", + "fileVersion": "8.19.1.26153" + } + } + }, + "Microsoft.IdentityModel.JsonWebTokens/8.19.1": { + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.1" + }, + "runtime": { + "lib/net10.0/Microsoft.IdentityModel.JsonWebTokens.dll": { + "assemblyVersion": "8.19.1.0", + "fileVersion": "8.19.1.26153" + } + } + }, + "Microsoft.IdentityModel.Logging/8.19.1": { + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.1" + }, + "runtime": { + "lib/net10.0/Microsoft.IdentityModel.Logging.dll": { + "assemblyVersion": "8.19.1.0", + "fileVersion": "8.19.1.26153" + } + } + }, + "Microsoft.IdentityModel.Tokens/8.19.1": { + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.IdentityModel.Logging": "8.19.1" + }, + "runtime": { + "lib/net10.0/Microsoft.IdentityModel.Tokens.dll": { + "assemblyVersion": "8.19.1.0", + "fileVersion": "8.19.1.26153" + } + } + }, + "MongoDB.Bson/3.9.0": { + "runtime": { + "lib/net6.0/MongoDB.Bson.dll": { + "assemblyVersion": "3.9.0.0", + "fileVersion": "3.9.0.0" + } + } + }, + "MongoDB.Driver/3.9.0": { + "dependencies": { + "DnsClient": "1.6.1", + "MongoDB.Bson": "3.9.0", + "SharpCompress": "0.48.1", + "Snappier": "1.3.1", + "ZstdSharp.Port": "0.7.3" + }, + "runtime": { + "lib/net6.0/MongoDB.Driver.dll": { + "assemblyVersion": "3.9.0.0", + "fileVersion": "3.9.0.0" + } + } + }, + "MongoDB.EntityFrameworkCore/10.0.2": { + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.9", + "MongoDB.Driver": "3.9.0" + }, + "runtime": { + "lib/net10.0/MongoDB.EntityFrameworkCore.dll": { + "assemblyVersion": "10.0.2.0", + "fileVersion": "10.0.2.0" + } + } + }, + "SharpCompress/0.48.1": { + "runtime": { + "lib/net10.0/SharpCompress.dll": { + "assemblyVersion": "0.48.1.0", + "fileVersion": "0.48.1.0" + } + } + }, + "Snappier/1.3.1": { + "runtime": { + "lib/net8.0/Snappier.dll": { + "assemblyVersion": "1.3.1.0", + "fileVersion": "1.3.1.0" + } + } + }, + "spworlds-api/1.0.3": { + "runtime": { + "lib/net8.0/spworlds-api.dll": { + "assemblyVersion": "1.0.3.0", + "fileVersion": "1.0.3.0" + } + } + }, + "System.IdentityModel.Tokens.Jwt/8.19.1": { + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.19.1", + "Microsoft.IdentityModel.Tokens": "8.19.1" + }, + "runtime": { + "lib/net10.0/System.IdentityModel.Tokens.Jwt.dll": { + "assemblyVersion": "8.19.1.0", + "fileVersion": "8.19.1.26153" + } + } + }, + "ZstdSharp.Port/0.7.3": { + "runtime": { + "lib/net7.0/ZstdSharp.dll": { + "assemblyVersion": "0.7.3.0", + "fileVersion": "0.7.3.0" + } + } + } + } + }, + "libraries": { + "SpMega.Backend/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + }, + "DnsClient/1.6.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg==", + "path": "dnsclient/1.6.1", + "hashPath": "dnsclient.1.6.1.nupkg.sha512" + }, + "Microsoft.Bcl.Cryptography/10.0.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==", + "path": "microsoft.bcl.cryptography/10.0.2", + "hashPath": "microsoft.bcl.cryptography.10.0.2.nupkg.sha512" + }, + "Microsoft.EntityFrameworkCore/10.0.9": { + "type": "package", + "serviceable": true, + "sha512": "sha512-tu85SRzOT021V7EQlViCiAE7TqldVn469Y6lt5TEn/+XC4/MeNCHgMRSxqYuWqvF4zAQZUhCmtNEZuM3ss4LeA==", + "path": "microsoft.entityframeworkcore/10.0.9", + "hashPath": "microsoft.entityframeworkcore.10.0.9.nupkg.sha512" + }, + "Microsoft.EntityFrameworkCore.Abstractions/10.0.9": { + "type": "package", + "serviceable": true, + "sha512": "sha512-GRMaiPkqYna/gCsyDffYDWmefGPC3hDrdMw+2rrGcQwhs6uZOsaMQXMJnoXQ35tx9SkBV2ieRRU9N/jLOO6BZw==", + "path": "microsoft.entityframeworkcore.abstractions/10.0.9", + "hashPath": "microsoft.entityframeworkcore.abstractions.10.0.9.nupkg.sha512" + }, + "Microsoft.EntityFrameworkCore.Relational/10.0.9": { + "type": "package", + "serviceable": true, + "sha512": "sha512-dEoyYKgiaZHHgOFm1WMWm1sFEsEuhPWufX4L9PekKtqd/RaIcPjkCjvbrVvJtApErb5wPSJhYvnTlxhH+p9h2g==", + "path": "microsoft.entityframeworkcore.relational/10.0.9", + "hashPath": "microsoft.entityframeworkcore.relational.10.0.9.nupkg.sha512" + }, + "Microsoft.IdentityModel.Abstractions/8.19.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-gFA8THIk23uNF/vMdOHnjIdXD1LyA2g12cHzMJ+Xag6WpgWLw6E/6uCXxvA0gp9d2yAvkRt3xzFzMUiO/hofnQ==", + "path": "microsoft.identitymodel.abstractions/8.19.1", + "hashPath": "microsoft.identitymodel.abstractions.8.19.1.nupkg.sha512" + }, + "Microsoft.IdentityModel.JsonWebTokens/8.19.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-6eeY+y2QFyjj3XnCz/8gJdoP5smYHTS9ow1bw2nsZzDIPjPhBZlackYTIduSMipVpxnoT/B62LkrXX2jPggOXg==", + "path": "microsoft.identitymodel.jsonwebtokens/8.19.1", + "hashPath": "microsoft.identitymodel.jsonwebtokens.8.19.1.nupkg.sha512" + }, + "Microsoft.IdentityModel.Logging/8.19.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-H+sMrMpdbWnwkQnpb/ESkQovtOgdefmj0ecGCcP40mDKzE5i4dUYkH6599M9mWYFNGNJnTp92l/9wLubYXWimw==", + "path": "microsoft.identitymodel.logging/8.19.1", + "hashPath": "microsoft.identitymodel.logging.8.19.1.nupkg.sha512" + }, + "Microsoft.IdentityModel.Tokens/8.19.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-KDiuSLXud2AFVNAOottd8ztVysfPeHyr4r8gofU3/VKUXlI7oytzGTnPsNJ/B3nui17rgz8wAdWNJOtzPjkUxw==", + "path": "microsoft.identitymodel.tokens/8.19.1", + "hashPath": "microsoft.identitymodel.tokens.8.19.1.nupkg.sha512" + }, + "MongoDB.Bson/3.9.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-J6vB61zKwMSfxbkN+/amGvj1qVMDKrKjV3kmoOWttMcv8JJScLDCFh89FyL3f2lDUyJrnfgZCR6/KX+07e99eg==", + "path": "mongodb.bson/3.9.0", + "hashPath": "mongodb.bson.3.9.0.nupkg.sha512" + }, + "MongoDB.Driver/3.9.0": { + "type": "package", + "serviceable": true, + "sha512": "sha512-XKUa+y5RtNH1iInfxj3Y7c1FN1BQ16/7hFxoqU6fzc3+BKM1D3mGa+pB/yBbAk8jNxf7+JWEnCfuQOyCo7dQLg==", + "path": "mongodb.driver/3.9.0", + "hashPath": "mongodb.driver.3.9.0.nupkg.sha512" + }, + "MongoDB.EntityFrameworkCore/10.0.2": { + "type": "package", + "serviceable": true, + "sha512": "sha512-GBN9+OAoIyBEgVPlYTXW3tyIsnpGPCA5AWZPy+X7ca3zJu+2Lli9FODZWl//w0yZHqwGwsLUL6XhcPPEaBYvBQ==", + "path": "mongodb.entityframeworkcore/10.0.2", + "hashPath": "mongodb.entityframeworkcore.10.0.2.nupkg.sha512" + }, + "SharpCompress/0.48.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A==", + "path": "sharpcompress/0.48.1", + "hashPath": "sharpcompress.0.48.1.nupkg.sha512" + }, + "Snappier/1.3.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA==", + "path": "snappier/1.3.1", + "hashPath": "snappier.1.3.1.nupkg.sha512" + }, + "spworlds-api/1.0.3": { + "type": "package", + "serviceable": true, + "sha512": "sha512-rLQsC3MiXBfPKf3v45Qcrj0yypfZktTPuT/eGUJpcAlSZix1FBSRUbQVtfXbA8BHGIqn7nhZMVetaP3dmk+n1g==", + "path": "spworlds-api/1.0.3", + "hashPath": "spworlds-api.1.0.3.nupkg.sha512" + }, + "System.IdentityModel.Tokens.Jwt/8.19.1": { + "type": "package", + "serviceable": true, + "sha512": "sha512-2VHcRtT95GAcW1E3aVBLvL2rAAMxKHXKMXKXFyWzwgkdFXZPMMvP8tVOfnRydL4vTr1RirNuGC6T8VSEF2YsPQ==", + "path": "system.identitymodel.tokens.jwt/8.19.1", + "hashPath": "system.identitymodel.tokens.jwt.8.19.1.nupkg.sha512" + }, + "ZstdSharp.Port/0.7.3": { + "type": "package", + "serviceable": true, + "sha512": "sha512-U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA==", + "path": "zstdsharp.port/0.7.3", + "hashPath": "zstdsharp.port.0.7.3.nupkg.sha512" + } + } +} \ No newline at end of file diff --git a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.runtimeconfig.json b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.runtimeconfig.json new file mode 100644 index 0000000..bf15a00 --- /dev/null +++ b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.runtimeconfig.json @@ -0,0 +1,20 @@ +{ + "runtimeOptions": { + "tfm": "net10.0", + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "10.0.0" + }, + { + "name": "Microsoft.AspNetCore.App", + "version": "10.0.0" + } + ], + "configProperties": { + "System.GC.Server": true, + "System.Reflection.NullabilityInfoContext.IsSupported": true, + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false + } + } +} \ No newline at end of file diff --git a/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.staticwebassets.endpoints.json b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.staticwebassets.endpoints.json new file mode 100644 index 0000000..2b6c535 --- /dev/null +++ b/backend/SpMega.Backend/bin/Debug/net10.0/SpMega.Backend.staticwebassets.endpoints.json @@ -0,0 +1,5 @@ +{ + "Version": 1, + "ManifestType": "Build", + "Endpoints": [] +} \ No newline at end of file diff --git a/backend/backend.sln.DotSettings.user b/backend/backend.sln.DotSettings.user new file mode 100644 index 0000000..c983623 --- /dev/null +++ b/backend/backend.sln.DotSettings.user @@ -0,0 +1,3 @@ + + ForceIncluded + ForceIncluded \ No newline at end of file diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java b/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java index b20a371..ef6a6f1 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ModMenuIntegration.java @@ -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); }); diff --git a/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java index b1bd754..9f7e68b 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/SPMegaClient.java @@ -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!"); diff --git a/src/main/java/git/yawaflua/tech/spmega/api/SPWorldsApiClient.java b/src/client/java/git/yawaflua/tech/spmega/client/api/SPWorldsApiClient.java similarity index 96% rename from src/main/java/git/yawaflua/tech/spmega/api/SPWorldsApiClient.java rename to src/client/java/git/yawaflua/tech/spmega/client/api/SPWorldsApiClient.java index 61f95be..afd4f59 100644 --- a/src/main/java/git/yawaflua/tech/spmega/api/SPWorldsApiClient.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/api/SPWorldsApiClient.java @@ -1,10 +1,10 @@ -package git.yawaflua.tech.spmega.api; +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.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; @@ -13,12 +13,12 @@ import java.util.Base64; import java.util.List; public final class SPWorldsApiClient { - private final HttpClient httpClient; + private final InstrumentedHttpClient httpClient; private final Gson gson; private final String baseUrl; public SPWorldsApiClient(String baseUrl) { - this.httpClient = HttpClient.newHttpClient(); + this.httpClient = new InstrumentedHttpClient(); this.gson = new Gson(); this.baseUrl = normalizeBaseUrl(baseUrl); } @@ -161,7 +161,7 @@ public final class SPWorldsApiClient { } private String send(HttpRequest request) throws IOException, InterruptedException { - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = httpClient.send(request); int statusCode = response.statusCode(); if (statusCode < 200 || statusCode >= 300) { System.out.println(response.body()); diff --git a/src/client/java/git/yawaflua/tech/spmega/client/qr/QRCodeScanner.java b/src/client/java/git/yawaflua/tech/spmega/client/qr/QRCodeScanner.java index b41399c..9f1d518 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/qr/QRCodeScanner.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/qr/QRCodeScanner.java @@ -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(), diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/InstrumentedHttpClient.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/InstrumentedHttpClient.java new file mode 100644 index 0000000..966b48a --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/InstrumentedHttpClient.java @@ -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 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 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> 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)); + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/PerformanceSampler.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/PerformanceSampler.java new file mode 100644 index 0000000..e2149a3 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/PerformanceSampler.java @@ -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)); + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/SystemInfoCollector.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/SystemInfoCollector.java new file mode 100644 index 0000000..3635bbe --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/SystemInfoCollector.java @@ -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 cachedIp = new AtomicReference<>(""); + private final AtomicReference gpuRenderer = new AtomicReference<>(""); + private final AtomicReference gpuVendor = new AtomicReference<>(""); + 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 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; + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryCollector.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryCollector.java new file mode 100644 index 0000000..00d7ebc --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryCollector.java @@ -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 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 drain() { + List batch = new ArrayList<>(); + TelemetryEvent event; + while ((event = queue.poll()) != null) { + batch.add(event); + } + return batch; + } + + public int size() { + return queue.size(); + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryEvent.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryEvent.java new file mode 100644 index 0000000..2f2e90c --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryEvent.java @@ -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()); + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetrySender.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetrySender.java new file mode 100644 index 0000000..95f064d --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetrySender.java @@ -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 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"; + } + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryUriSanitizer.java b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryUriSanitizer.java new file mode 100644 index 0000000..a1d4b30 --- /dev/null +++ b/src/client/java/git/yawaflua/tech/spmega/client/telemetry/TelemetryUriSanitizer.java @@ -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 ""; + String path = uri.getPath() != null ? uri.getPath() : ""; + path = UUID_PATTERN.matcher(path).replaceAll(""); + + String query = uri.getQuery(); + if (query != null) { + query = TOKEN_PATTERN.matcher(query).replaceAll("$1="); + 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 ""; + } + } + + 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"; + } +} diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java index cf8b0d7..b748be6 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/GpsHudRenderer.java @@ -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 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) { diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java index 1d67163..b153a6c 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BackendAuthenticator.java @@ -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 response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse 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 response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse 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 response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse 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 response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse 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 response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse 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()); diff --git a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java index 9b22df2..5ca49d0 100644 --- a/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java +++ b/src/client/java/git/yawaflua/tech/spmega/client/ui/service/BankUiService.java @@ -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, diff --git a/src/client/resources/assets/spmega/lang/en_us.json b/src/client/resources/assets/spmega/lang/en_us.json index 4e4e442..efdb460 100644 --- a/src/client/resources/assets/spmega/lang/en_us.json +++ b/src/client/resources/assets/spmega/lang/en_us.json @@ -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" } diff --git a/src/client/resources/assets/spmega/lang/ru_ru.json b/src/client/resources/assets/spmega/lang/ru_ru.json index df8caa1..f12c5c9 100644 --- a/src/client/resources/assets/spmega/lang/ru_ru.json +++ b/src/client/resources/assets/spmega/lang/ru_ru.json @@ -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" } diff --git a/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java b/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java index c5b7f74..86c081a 100644 --- a/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java +++ b/src/main/java/git/yawaflua/tech/spmega/ConfigManager.java @@ -66,7 +66,26 @@ public final class ConfigManager { shouldSave = true; } - ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition); + boolean telemetryEnabled = readBoolean(properties, "telemetry.enabled", defaults.telemetryEnabled()); + String rawTelemetry = properties.getProperty("telemetry.enabled"); + if (rawTelemetry == null || !Boolean.toString(telemetryEnabled).equalsIgnoreCase(rawTelemetry.trim())) { + shouldSave = true; + } + + int telemetryIntervalSeconds = readInt(properties, "telemetry.intervalSeconds", defaults.telemetryIntervalSeconds()); + String rawInterval = properties.getProperty("telemetry.intervalSeconds"); + if (rawInterval == null || !Integer.toString(telemetryIntervalSeconds).equals(rawInterval.trim())) { + shouldSave = true; + } + + boolean telemetryCollectSystemInfo = readBoolean(properties, "telemetry.collectSystemInfo", defaults.telemetryCollectSystemInfo()); + String rawSysInfo = properties.getProperty("telemetry.collectSystemInfo"); + if (rawSysInfo == null || !Boolean.toString(telemetryCollectSystemInfo).equalsIgnoreCase(rawSysInfo.trim())) { + shouldSave = true; + } + + ModConfig config = new ModConfig(apiDomain, apiToken, allowAccess, signQuickPayEnabled, gpsEnabled, gpsPosition, + telemetryEnabled, telemetryIntervalSeconds, telemetryCollectSystemInfo); if (shouldSave) { @@ -129,6 +148,9 @@ public final class ConfigManager { properties.setProperty("sign.quickPay.enabled", Boolean.toString(config.signQuickPayEnabled())); properties.setProperty("gps.enabled", Boolean.toString(config.gpsEnabled())); properties.setProperty("gps.position", config.gpsPosition().name()); + properties.setProperty("telemetry.enabled", Boolean.toString(config.telemetryEnabled())); + properties.setProperty("telemetry.intervalSeconds", Integer.toString(config.telemetryIntervalSeconds())); + properties.setProperty("telemetry.collectSystemInfo", Boolean.toString(config.telemetryCollectSystemInfo())); try { Files.createDirectories(configPath.getParent()); @@ -140,6 +162,18 @@ public final class ConfigManager { } } + private static int readInt(Properties properties, String key, int fallback) { + String value = properties.getProperty(key); + if (value == null || value.trim().isEmpty()) { + return fallback; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException exception) { + return fallback; + } + } + private static > E readEnum(Properties properties, String key, Class enumClass, E fallback) { String value = properties.getProperty(key); if (value == null || value.trim().isEmpty()) { diff --git a/src/main/java/git/yawaflua/tech/spmega/ModConfig.java b/src/main/java/git/yawaflua/tech/spmega/ModConfig.java index 4573659..a6fb7ed 100644 --- a/src/main/java/git/yawaflua/tech/spmega/ModConfig.java +++ b/src/main/java/git/yawaflua/tech/spmega/ModConfig.java @@ -1,13 +1,17 @@ package git.yawaflua.tech.spmega; public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, boolean signQuickPayEnabled, - boolean gpsEnabled, GpsHudPosition gpsPosition) { + boolean gpsEnabled, GpsHudPosition gpsPosition, + boolean telemetryEnabled, int telemetryIntervalSeconds, boolean telemetryCollectSystemInfo) { public static final String DEFAULT_API_DOMAIN = "https://spmega.yawaflua.tech"; public static final boolean ALLOW_BACKEND = true; public static final String DEFAULT_API_TOKEN = "-"; public static final boolean DEFAULT_SIGN_QUICK_PAY_ENABLED = true; public static final boolean DEFAULT_GPS_ENABLED = true; public static final GpsHudPosition DEFAULT_GPS_POSITION = GpsHudPosition.TOP_CENTER; + public static final boolean DEFAULT_TELEMETRY_ENABLED = true; + public static final int DEFAULT_TELEMETRY_INTERVAL_SECONDS = 60; + public static final boolean DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO = true; public static ModConfig createDefault() { return new ModConfig( @@ -16,9 +20,10 @@ public record ModConfig(String apiDomain, String apiToken, boolean allowBackend, ALLOW_BACKEND, DEFAULT_SIGN_QUICK_PAY_ENABLED, DEFAULT_GPS_ENABLED, - DEFAULT_GPS_POSITION + DEFAULT_GPS_POSITION, + DEFAULT_TELEMETRY_ENABLED, + DEFAULT_TELEMETRY_INTERVAL_SECONDS, + DEFAULT_TELEMETRY_COLLECT_SYSTEM_INFO ); } } - - diff --git a/src/main/java/git/yawaflua/tech/spmega/SPMega.java b/src/main/java/git/yawaflua/tech/spmega/SPMega.java index b3b87bc..d2e911c 100644 --- a/src/main/java/git/yawaflua/tech/spmega/SPMega.java +++ b/src/main/java/git/yawaflua/tech/spmega/SPMega.java @@ -16,6 +16,9 @@ public class SPMega implements ModInitializer { @Override public void onInitialize() { + long startNs = System.nanoTime(); config = ConfigManager.loadOrCreate(); + long durationMs = (System.nanoTime() - startNs) / 1_000_000L; + System.out.println("[SPMEGA] Main init took " + durationMs + "ms"); } }