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
+2 -1
View File
@@ -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
@@ -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<AuthController> 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<MojangDto>();
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);
}
@@ -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 });
}
}
@@ -0,0 +1,50 @@
using System.Diagnostics;
namespace SpMega.Backend.Middleware;
public class RequestTelemetryMiddleware(RequestDelegate next, ILogger<RequestTelemetryMiddleware> 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);
}
}
}
@@ -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<AppDbContext> options) : DbContext(op
internal DbSet<Transaction> Transactions { get; set; }
internal DbSet<User> Users { get; set; }
internal DbSet<UserSession> UserSessions { get; set; }
internal DbSet<ModTelemetryDocument> ModTelemetry { get; set; }
internal DbSet<BackendRequestTelemetryDocument> BackendRequestTelemetry { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -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<ModTelemetryEventDto> Events
);
@@ -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;
}
@@ -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<ModTelemetryEventDocument> Events { get; set; } = [];
}
public class ModTelemetryEventDocument
{
public string EventType { get; set; } = string.Empty;
public DateTimeOffset Timestamp { get; set; }
public string PayloadJson { get; set; } = "{}";
}
+21 -3
View File
@@ -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<string>("Mongo") ?? "mongodb://curiosity:27018");
serviceCollection.AddSingleton<IMongoClient>(mongoClient);
builder.Services.AddSingleton<IMongoClient>(mongoClient);
builder.Services.AddDbContext<AppDbContext>((_, 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<RequestTelemetryMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/api"), appBuilder =>
@@ -22,6 +22,9 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="MongoDB.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="spworlds-api" Version="1.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
</ItemGroup>
@@ -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"
}
}
}
@@ -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
}
}
}
@@ -0,0 +1,5 @@
{
"Version": 1,
"ManifestType": "Build",
"Endpoints": []
}
+3
View File
@@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APaymentWrapper_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F28d7b99649ed43d899c3d8518f41c1976400_003F94_003Fbc0e580e_003FPaymentWrapper_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASPWorlds_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F28d7b99649ed43d899c3d8518f41c1976400_003F64_003Fd3041423_003FSPWorlds_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
@@ -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!");
@@ -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<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request);
int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
System.out.println(response.body());
@@ -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"
}
@@ -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 extends Enum<E>> E readEnum(Properties properties, String key, Class<E> enumClass, E fallback) {
String value = properties.getProperty(key);
if (value == null || value.trim().isEmpty()) {
@@ -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
);
}
}
@@ -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");
}
}