Analytics Systeem¶
Doelstelling¶
Als developers willen wij informatie verzamelen over onze spelers, zodat wij kunnen weten hoe wij ons spel kunnen verbeteren.
Toegepaste oplossing¶
Shared¶
Het formaat van de informatie dat verstuurd word is hetzelfde op beide de server en client, om die reden heb ik deze structuren in shared geplaatst.
ApiResponse¶
ApiResponse is een algemene type dat alle soorten data omringt, maar met de mogelijkheid om de data ook niet vast te houden. Daarmee kan het ook errors vasthouden in dezelfde type.
Het probeert ook de Error enum van de programmeertaal Rust na te bootsen met de AsProblem en AsValue methods. Omdat het zorgt voor een veel betere error afhandeling dat je niet kan vergeten. Alleen is dit niet compleet mogelijk zoals in Rust.
public class ApiResponse<T> where T : struct
{
[JsonInclude]
public bool Success => data is not null;
[JsonInclude]
protected Nullable<T> data;
[JsonInclude]
protected HttpStatusCode? status;
[JsonInclude]
protected string? title;
[JsonInclude]
protected string? detail;
Hier is de code minder netjes.
Maar het is noodzakelijk omdat als de Problem in data zou worden opgeslagen,
dan zou het niet voldoen aan RFC7807 wat de application/problem+json
response type definieert. (M. Nottingham & E. Wilde, 2016)
Gelukkig is deze code verstopt door het protected te maken.
protected Problem? GetProblem()
=> status is not null
&& title is not null
&& title != string.Empty
? new((HttpStatusCode)status, title, detail)
: null;
protected ApiResponse<T> SetProblem(Problem? p)
{
status = p?.status;
title = p?.title;
detail = p?.detail;
return this;
}
[JsonConstructor]
protected ApiResponse(Nullable<T> data)
{
this.data = data;
}
public static ApiResponse<T> FromSuccess(T data)
=> new(data);
public static ApiResponse<T> FromError(Problem err)
{
var response = new ApiResponse<T>(null);
response.SetProblem(err);
return response;
}
public T AsValue()
=> data is not null ? (T)data : throw new InvalidCastException("Tried getting value from ApiResponse that contains a Problem instead");
public Problem AsProblem()
=> (Problem)GetProblem();
public override string ToString()
=> Success
? $"ApiResponse<{typeof(T).FullName}> Success {JsonSerializer.Serialize(AsValue(), NetJson.JSON_OPTIONS)}"
: $"ApiResponse<{typeof(T).FullName}> {AsProblem()}";
}
Verschillende data¶
De data structs die verstuurd worden zelf zijn natuurlijk minder interessant, ze houden alleen dat vast.
Hier is een voorbeeld van de gespeelde game data struct, omdat het heel veel en overbodig zou zijn om elke struct hier te laten zien.
public struct GameData
{
public required bool won;
public required int length;
public required int wavesBeaten;
public required int levelReached;
public required int enemiesDefeated;
public required int healthLost;
public required int damageDone;
public string favouriteWeapon;
}
Server¶
Als eerste heb ik de server-database gedeelte van het systeem gemaakt. Hiervoor heb ik de server met alles erop en eraan van het vorige project gebruikt. Hieronder valt ASP.net en de MySQLConnector.
Repositories¶
Net zoals met het vorige project heb ik de model die word opgeslagen in de database en de queries die erop worden toegepast samen gestopt in repositories. Hiervoor heb ik dan natuurlijk de reader gebruikt van het vorige project. (B. Oskam, 2024)
Hier is als voorbeeld de model en repository van exceptions.
public class ExceptionModel
{
public int id;
[DatabasePropertyName(name = "user_id")]
public int? userId;
public string? source;
public string message;
[DatabasePropertyName(name = "target_site")]
public string? targetSite;
[DatabasePropertyName(name = "helplink")]
public string? helpLink;
public string data;
public string stacktrace;
[DatabasePropertyName(name = "created_at")]
public DateTime createdAt;
}
En hieronder de repository. Alleen voor nu word de method om echt exceptions te maken nog gebruikt.
public static class ExceptionRepository
{
public const string TableName = "Exception";
public static async Task<IAsyncEnumerable<ExceptionModel>> GetWithLimit(MySqlConnection conn, int limit = 50)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
SELECT
*
FROM
`{TableName}`
LIMIT
@limit
";
cmd.Parameters.AddWithValue("@limit", limit);
using var reader = await cmd.ExecuteReaderAsync();
return DatabaseReader.ReadAllDynamicAsync<ExceptionModel>(reader);
}
public static async Task<long> Create(MySqlConnection conn, int? userId, ExceptionData data)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
INSERT INTO
`{TableName}`
(`user_id`, `source`, `message`, `target_site`, `helplink`, `data`, `stacktrace`)
VALUES
(@userId, @source, @message, @targetSite, @helpLink, @data, @stacktrace)
";
cmd.Parameters.AddRange(new MySqlParameter[] {
new("@userId", userId),
new("@source", data.source),
new("@message", data.message),
new("@targetSite", data.targetSite),
new("@helpLink", data.helpLink),
new("@data", data.data),
new("@stacktrace", data.stacktrace),
});
await cmd.ExecuteNonQueryAsync();
return cmd.LastInsertedId;
}
}
Controllers¶
In het vorige project konden wij geen controller gebruik omdat wij een live verbindingen nodig hadden voor multiplayer. Maar nu hebben wij deze requirement niet, dus heb ik controllers gebruikt.
Alleen het probleem met controllers is dat je niet gemakkelijk kan zeggen dat een bepaalde groep onder een bepaalde pad moet staan. Dit heb ik dus geprobeerd na te bootsen met de BaseApiController en de V1ApiController eronder. De BaseApiController is er alleen voor zodat je niet de ApiController attribuut hoeft te herhalen voor elke controller, samen met welke auth scheme gebruikt moet worden wanneer je wilt dat de gebruiker geautoriseerd moet zijn voor een bepaalde method. En de V1ApiController is er echt alleen voor zodat de route attribuut niet herhaald hoeft te worden. Maar het maakt het ook mogelijk om in de toekomst bijvoorbeeld een andere auth scheme te gebruiken voor nieuwe versies van de api.
[ApiController]
[Route("api/[controller]")]
public abstract class BaseApiController : Controller
{
public const string AUTH_SCHEMES = "ApiBearer";
}
[Route("api/v1/[controller]")]
public abstract class V1ApiController : BaseApiController
{ }
En hier is als voorbeeld de user controller. Het doet alles dat de andere controllers ook hier en daar doen, dus het is een mooi voorbeeld.
public class UserController : V1ApiController
{
protected readonly ILogger<UserController> log;
protected readonly MySqlDataSource pool;
public UserController(ILogger<UserController> logger, MySqlDataSource connectionPool) : base()
{
log = logger;
pool = connectionPool;
}
[HttpPost("register")]
public async Task<IActionResult> Register()
{
Hier word gestuurde informatie over het platform van de gebruiker ingelezen, met error afhandeling natuurlijk.
RegisterUserData data;
try
{
data = await JsonSerializer.DeserializeAsync<RegisterUserData>(Request.Body, NetJson.JSON_OPTIONS);
}
catch (Exception e)
{
log.LogInformation("User sent invalid json body: {}", e);
return ApiResult<UserRegisteredData>.FromError(new(HttpStatusCode.BadRequest, "failed to parse given body", e.Message));
}
if (data.platform.Length > 64)
return ApiResult<UserRegisteredData>.FromError(new(HttpStatusCode.RequestEntityTooLarge, "given platform is too long"));
var token = UserRepository.GenerateToken();
using var conn = await pool.OpenConnectionAsync();
var id = await UserRepository.Create(
conn,
token,
HttpContext.Connection.RemoteIpAddress?.MapToIPv6()?.ToString() ?? "::1",
data
);
return ApiResult<UserRegisteredData>.FromData(
new UserRegisteredData{
token = $"{id}|{token}"
}
);
}
Deze method heb ik er in laten staan voor debuggen, omdat het zeer handig is als je een server online zet. Het vereist bijvoorbeeld dat de gebruiker is geautoriseerd.
Maar natuurlijk willen wij deze informatie niet echt beschikbaar maken, dus is het alleen beschikbaar als de server niet voor release gecompileerd word.
#if DEBUG
[Authorize(AuthenticationSchemes = AUTH_SCHEMES)]
[HttpGet("self")]
public async Task<IActionResult> GetSelf()
{
var success = int.TryParse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value, out var id);
if (!success)
return ApiResult<Message>.FromError(new(HttpStatusCode.BadRequest, "not properly authenticated"));
using var conn = await pool.OpenConnectionAsync();
var user = await UserRepository.GetFromId(conn, id);
return ApiResult<Debug>.FromData(new(user));
}
#endif
}
ApiActionResult¶
Alleen kan je niet zomaar elke soort data teruggeven vanuit een controller method. Om deze rede heb ik deze speciale ApiActionResult gemaakt, die het gemakkelijk maakt om altijd een ApiResponse terug te geven.
Stiekem gebruikt het simpelweg de JsonActionResult, maar dan natuurlijk met extra stappen om te voldoen aan RFC7807.
public class ApiResult<T> : IActionResult where T : struct
{
protected readonly ApiResponse<T> response;
protected ApiResult(ApiResponse<T> apiResponse)
=> response = apiResponse;
public static ApiResult<T> FromData(T data)
=> new(ApiResponse<T>.FromSuccess(data));
public static ApiResult<T> FromError(Problem problem)
=> new(ApiResponse<T>.FromError(problem));
public Task ExecuteResultAsync(ActionContext context)
{
var result = response.Success
? new JsonResult(response, NetJson.JSON_OPTIONS)
: new JsonResult(response, NetJson.JSON_OPTIONS)
{
StatusCode = (int)response.AsProblem()!.status,
ContentType = "application/problem+json"
};
return result.ExecuteResultAsync(context);
}
}
Authentication¶
Om ervoor te zorgen dat spellen en sessies altijd horen bij een gebruiker heb ik authentication toegevoegd door middel van een bearer token. Hiervoor heb ik deze speciale AuthenticationHandler gemaakt die ook foute requests in de standaard formaat teruggeven.
public class ApiTokenAuthenticator : AuthenticationHandler<AuthenticationSchemeOptions>
{
protected override string ClaimsIssuer
=> "ApiTokenAuthenticator";
protected readonly MySqlDataSource pool;
public ApiTokenAuthenticator(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MySqlDataSource connectionPool)
: base(options, logger, encoder)
{
pool = connectionPool;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
Eerst word hier de token gepakt en ontleed.
De tokens heb ik in {id}|{token}
formaat gemaakt zodat ze sneller uit de database gevonden kunnen worden.
Maar voor de zekerheid werkt het ook zonder de id.
var hasAuth = Request.Headers.TryGetValue("Authorization", out var auth);
if (!hasAuth)
return AuthenticateResult.Fail("Missing Authorization Header");
var token = AuthenticationHeaderValue.Parse(auth!).Parameter;
if (string.IsNullOrEmpty(token))
return AuthenticateResult.Fail("Empty token");
int? id = null;
string code;
{
var split = token.Split("|", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (split.Length == 1)
{
code = split[0];
}
else
{
var parsed = int.TryParse(split[0].AsSpan(), out var idTemp);
if (!parsed)
return AuthenticateResult.Fail("Invalid token");
id = idTemp;
code = split[1];
}
}
Hieronder word dan de user repository gebruikt om de gevonden token op te zoeken in de database. Als die dan gevonden is worden er ASP.net claims gebruikt om de gebruiker te identificeren.
using var conn = await pool.OpenConnectionAsync();
var user = id is not null
? await UserRepository.GetFromTokenWithId(conn, code, (int)id)
: await UserRepository.GetFromToken(conn, code);
if (user is null)
return AuthenticateResult.Fail("Invalid token");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.id.ToString(), ClaimValueTypes.Integer, ClaimsIssuer),
new Claim(ClaimTypes.Authentication, $"{user.id}|{code}", ClaimValueTypes.String, ClaimsIssuer)
};
return AuthenticateResult.Success(
new AuthenticationTicket(
new ClaimsPrincipal(
new ClaimsIdentity(claims, Scheme.Name)
),
Scheme.Name
)
);
}
Als laatste hieronder word veranderd hoe op foutieve requests word gereageerd. Zodat elke reactie json is en in het ApiResponse formaat.
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.ContentType = "application/problem+json";
Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return Response.WriteAsync(
JsonSerializer.Serialize(
ApiResponse<Message>.FromError(
new(HttpStatusCode.Unauthorized, "Unauthorized", "Unauthorized for this method.")
),
NetJson.JSON_OPTIONS
)
);
}
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
{
Response.ContentType = "application/problem+json";
Response.StatusCode = (int)HttpStatusCode.Forbidden;
return Response.WriteAsync(
JsonSerializer.Serialize(
ApiResponse<Message>.FromError(
new(HttpStatusCode.Forbidden, "Forbidden", "Unauthorized and not allowed to access this method.")
),
NetJson.JSON_OPTIONS
)
);
}
}
Client¶
Api Endpoints¶
Wij hebben natuurlijk versioning gebruikt op de api om ervoor te zorgen dat het gemakkelijk is om het te updaten. Maar als wij elke request willekeurig door de code heen plaatsen is het veel moeilijker om het te updaten naar de nieuwe versie. Om die reden, en natuurlijk voor de overzichtelijkheid, heb ik alle endpoints in methods in één static class gestopt.
Dit heb ik allemaal in één bestand gedaan omdat elke method zelf niet heel ingewikkeld is, en eigenlijk zijn ze allemaal heel gelijk. Ook kan ik hiermee subclassen maken om het alsnog een beetje te verdelen.
Alle subclassen behalve die van sessies heb ik weggelaten voor de overzichtelijkheid. Ook is elke andere method maar een variant van een van deze twee methods.
public static class AnalyticsApiEndpoints
{
public static class Session
{
public static async Task<ApiResponse<SessionData>?> Create(HttpClient http)
{
var result = await http.SendAsync(new(HttpMethod.Post, "api/v1/session/create"));
return await result.Content.ReadFromJsonAsync<ApiResponse<SessionData>>(NetJson.JSON_OPTIONS);
}
public static async Task<bool> End(HttpClient http, int sessionId)
{
var result = await http.SendAsync(new(HttpMethod.Post, $"api/v1/session/update/{sessionId}"));
var response = await result.Content.ReadFromJsonAsync<ApiResponse<Message>>(NetJson.JSON_OPTIONS);
return response?.Success == true;
}
}
}
Analytics Systeem¶
Als laatste zijn wij bij het analytics systeem zelf, dat informatie ophaalt uit het spel en stuurt naar de server.
Het is op een zulke manier geschreven dat het de rest van het spel niet bederft, zelfs als het compleet stopt met functioneren. In dat geval stuurt het gewoon geen analytics, maar worden ze nog wel gelogd voor in het geval dat dat nodig is.
public class AnalyticsSystem : BaseSystem
{
protected const string SendExceptionsSetting = "sendExceptions";
protected const string SendAnalyticsSetting = "sendAnalytics";
protected const string UserTokenSetting = "userToken";
protected SettingsSystem settings;
protected HttpClient http = new()
{
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
DefaultRequestVersion = HttpVersion.Version20
};
public bool Enabled { get; protected set; } = false;
Hier worden de instellingen qua het analytics systeem beschikbaar gemaakt, terwijl zij in het instellingen systeem echt worden opgeslagen. Hierdoor is het gemakkelijk om ergens anders in het spel, bijvoorbeeld de ui, dit aan en uit te zetten. Dan word alles, zoals het registreren en een sessie starten, geregeld.
public bool ExceptionsEnabled
{
get => settings.GetValueOrDefault(SendExceptionsSetting, false);
set => settings.Set(SendExceptionsSetting, value);
}
public bool AnalyticsEnabled
{
get => settings.GetValueOrDefault(SendAnalyticsSetting, false);
set {
settings.Set(SendExceptionsSetting, value);
if (!Enabled)
AttemptEnable()
.CatchWithoutAwaiting(e
=> App.Logger.LogWarning("Failed to enable analytics: {}", e)
);
}
}
protected string? token;
protected string? Token
{
get => token;
set {
token = value;
http.DefaultRequestHeaders.Authorization = token is null
? null
: new AuthenticationHeaderValue("Bearer", token);
settings.Set(UserTokenSetting, value);
}
}
protected int? sessionId = null;
protected TimeSpan gameStart;
protected GameData gameData;
protected bool sentExceptions = false;
public override void LoadContent()
{
settings = systemHandler.GetSystem<SettingsSystem>();
var serverAddress = systemHandler.GetSystem<ServerAddressSystem>();
if (!serverAddress.Loaded)
return;
http.BaseAddress = serverAddress.Address.GetUri();
token = settings.GetValueOrDefault<string?>(UserTokenSetting);
http.DefaultRequestHeaders.Authorization = token is null
? null
: new AuthenticationHeaderValue("Bearer", token);
if (AnalyticsEnabled)
AttemptEnable()
.CatchWithoutAwaiting(e
=> App.Logger.LogWarning("Failed to enable analytics: {}", e)
);
Hier word naar alle verschillende events geluisterd van het spel om data te verzamelen. Ik moest zelfs een paar extra events toevoegen.
Alleen wanneer de speler het spel sluit (niet met een crash) en wanneer een gespeelde game eindigt, word hier informatie gestuurd naar de server. En natuurlijk alleen als het opzetten daarvan is gelukt.
var eventBus = systemHandler.GetSystem<EventBus>();
eventBus.Subscribe<GameStartedEvent>(e => {
gameStart = App.LastUpdateTime.TotalGameTime;
gameData = default;
});
eventBus.Subscribe<GameEndedEvent>(e => {
gameData.length = (int)(App.LastUpdateTime.TotalGameTime - gameStart).TotalSeconds;
gameData.wavesBeaten = e.game.waves.currentWave - 1;
gameData.levelReached = e.game.Player.Level;
App.Logger.LogInformation("Game completed: {}", JsonSerializer.Serialize(gameData, NetJson.JSON_OPTIONS));
if (Enabled && AnalyticsEnabled)
SendPlayedGame(gameData)
.CatchWithoutAwaiting(e
=> App.Logger.LogWarning("Failed to send played game: {}", e)
);
});
eventBus.Subscribe<EntityDiedEvent>(e => {
if (e.entity is Player)
return;
gameData.enemiesDefeated ++;
});
eventBus.Subscribe<EntityDamagedEvent>(e => {
if (e.entity is Player)
{
gameData.healthLost += e.damage;
}
else
{
gameData.damageDone += e.damage;
}
});
eventBus.Subscribe<GameClosedEvent>(e => {
if (Enabled && AnalyticsEnabled)
AnalyticsApiEndpoints.Session.End(http, (int)sessionId!)
.CatchWithoutAwaiting(e
=> App.Logger.LogWarning("Failed to end session: {}", e)
);
});
}
public override void Update(GameTime gameTime)
{
if (sentExceptions || !Enabled || !ExceptionsEnabled)
return;
SendExceptions()
.CatchWithoutAwaiting(e
=> App.Logger.LogWarning("Failed to send exceptions: {}", e)
);
sentExceptions = true;
}
Hier word echt het analytics systeem opgestart. Het probeert te registreren, als dat niet al was gebeurt, samen met informatie over het huidige installatie. Dan maakt het een sessie dat voor elke volgende stuk analytics word gebruikt.
protected async Task<bool> AttemptEnable()
{
if (Enabled)
throw new InvalidOperationException("Attempting to enable analytics, which are already enabled.");
using var _ = App.Logger.BeginScope("analytics");
App.Logger.LogInformation("Attempting to enable analytics...");
if (Token is null)
{
var registerResult = await AnalyticsApiEndpoints.User.Register(http, new RegisterUserData {
#if DEBUG
platform = $"{RuntimeInformation.RuntimeIdentifier} {{ DEBUG }}",
#else
platform = RuntimeInformation.RuntimeIdentifier,
#endif
gameBuildDate = BuildInfo.BuiltAt
});
if (registerResult?.Success != true)
{
App.Logger.LogWarning("Failed to register as user {}", registerResult?.AsProblem());
return false;
}
Token = registerResult.AsValue().token;
}
var sessionResult = await AnalyticsApiEndpoints.Session.Create(http);
if (sessionResult?.Success != true)
{
App.Logger.LogWarning("Failed to create session {}", sessionResult?.AsProblem());
return false;
}
sessionId = sessionResult.AsValue().id;
Enabled = true;
return true;
}
En hier worden de gemaakte exceptions verstuurd, die eerder niet waren opgevangen. Daarna worden ze verplaatst naar een algemene logs folder, zodat ze niet per ongeluk opnieuw worden verstuurd.
Het werkt onafhankelijk van of de speler geregistreerd is of niet, zodat wij niet mogelijk exceptions missen van spelers die liever niet analytics willen delen.
protected async Task SendExceptions()
{
var sentLogsFolder = Path.Join(App.ExecutablePath, "/log/crash");
Directory.CreateDirectory(sentLogsFolder);
var exceptionsSent = 0;
foreach (var filePath in Directory.EnumerateFiles(App.ExecutablePath, "crash *.log"))
{
var name = Path.GetFileName(filePath);
ExceptionData data;
try
{
data = JsonSerializer.Deserialize<ExceptionData>(File.ReadAllText(filePath), NetJson.JSON_OPTIONS);
}
catch (Exception e)
{
App.Logger.LogWarning("Failed to parse crash log '{}', {}", name, e);
continue;
}
await AnalyticsApiEndpoints.Exception.Send(http, data);
File.Move(filePath, Path.Join(sentLogsFolder, name));
exceptionsSent++;
}
if (exceptionsSent > 0)
App.Logger.LogInformation("Sent {} crash log exception(s) to the server", exceptionsSent);
}
protected async Task SendPlayedGame(GameData game)
{
await AnalyticsApiEndpoints.Game.Send(http, (int)sessionId!, game);
}
}
Sequentiediagram¶
Bronnen¶
- C# documentation. (z.d.) learn.microsoft.com.
Laatst geraadpleegd op 30 mei 2024, van https://learn.microsoft.com/en-uk/dotnet/csharp - ASP.net documentation. (z.d.) learn.microsoft.com.
Laatste geraadpleegd op 28 mei 2024, van https://learn.microsoft.com/en-uk/aspnet/core - MySQLConnector API. (z.d.) mysqlconnector.net.
Laatst geraadpleegd op 28 mei 2024, van https://mysqlconnector.net/api - MySQl Reference Manual. (z.d.) dev.mysql.com.
Laatst geraadpleegd op 28 mei 2024, van https://dev.mysql.com/doc/refman/8.0/en/ - M. Nottingham & E. Wilde. (2016, maart). RFC7807. Problem Details for HTTP APIs. datatracker.ietf.org.
Geraadpleegd op 31 mei 2024, van https://datatracker.ietf.org/doc/html/rfc7807 - B. Oskam. (2024, maart 25). Herschreven Server Database Verbinding. vuujoofeexuu44-propedeuse-hbo-ict-onderwijs-2023-ce90f71125f313.dev.hihva.nl.
Geraadpleegd van https://vuujoofeexuu44-propedeuse-hbo-ict-onderwijs-2023-ce90f71125f313.dev.hihva.nl/logboek/herschreven%20server/database%20verbinding/
Gecreëerd: May 18, 2024