telemetry: add opt-in client-applied reporter

adds ClientAppliedReporter that fires a single bounded (5s) best-effort
POST after a successful update, carrying only release format and
version. the launcher config exposes TelemetryUrlTemplate defaulted to
empty string — when empty the reporter short-circuits and nothing goes
over the network.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-14 21:05:49 +02:00
parent ee7edfd990
commit 3d98ac4470
2 changed files with 115 additions and 0 deletions

View File

@@ -30,6 +30,14 @@ public static class LauncherConfig
public const int MaxBlobRetries = 3;
/// <summary>
/// Opt-in telemetry endpoint for the "client applied release" ping. Empty
/// by default — when empty the reporter short-circuits and no network call
/// is made. Supports <c>{format}</c> and <c>{version}</c> placeholders,
/// both URL-escaped at send time.
/// </summary>
public const string TelemetryUrlTemplate = "";
/// <summary>Name of the directory under the client root that holds launcher state.</summary>
public const string StateDirName = ".updates";

View File

@@ -0,0 +1,107 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using Metin2Launcher.Logging;
namespace Metin2Launcher.Telemetry;
/// <summary>
/// Opt-in "client applied this release" ping. Fires at most once per update
/// run, after the apply phase succeeds, and only if a non-empty template URL
/// is configured on the launcher settings. The caller passes in an
/// <see cref="HttpClient"/> that the reporter does NOT own.
///
/// Design rules:
/// - timeout is strictly bounded (5 seconds) — telemetry never blocks the
/// launch flow
/// - failures are always swallowed and logged as warnings
/// - nothing personally identifying is sent: just format, version and a
/// best-effort machine identifier derived from the env
/// - no retries, no queueing, no persistent buffer
/// </summary>
public sealed class ClientAppliedReporter
{
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private readonly HttpClient _http;
private readonly string? _urlTemplate;
public ClientAppliedReporter(HttpClient http, string? urlTemplate)
{
_http = http;
_urlTemplate = urlTemplate;
}
/// <summary>True when the reporter is actually wired to a destination.</summary>
public bool IsEnabled => !string.IsNullOrWhiteSpace(_urlTemplate);
/// <summary>
/// Sends a single POST to the configured URL. Returns <c>true</c> if the
/// server accepted it (2xx), <c>false</c> on any other outcome. Never throws.
/// </summary>
public async Task<bool> ReportAsync(string format, string version, CancellationToken ct)
{
if (!IsEnabled)
return false;
try
{
var url = Expand(_urlTemplate!, format, version);
var body = JsonSerializer.SerializeToUtf8Bytes(new Payload
{
Format = format,
Version = version,
Timestamp = DateTime.UtcNow.ToString("o"),
});
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(Timeout);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new ByteArrayContent(body),
};
req.Content.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
using var resp = await _http.SendAsync(req, linked.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
Log.Warn($"telemetry: client-applied POST returned {(int)resp.StatusCode}");
return false;
}
Log.Info($"telemetry: reported client-applied format={format} version={version}");
return true;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Log.Warn($"telemetry: client-applied POST timed out after {Timeout.TotalSeconds}s");
return false;
}
catch (Exception ex)
{
Log.Warn("telemetry: client-applied POST failed: " + ex.Message);
return false;
}
}
/// <summary>
/// Expands <c>{format}</c> and <c>{version}</c> placeholders in the URL
/// template. Case-sensitive, by design — we want obvious failures if the
/// template is wrong.
/// </summary>
public static string Expand(string template, string format, string version)
{
var sb = new StringBuilder(template);
sb.Replace("{format}", Uri.EscapeDataString(format));
sb.Replace("{version}", Uri.EscapeDataString(version));
return sb.ToString();
}
private sealed class Payload
{
public string Format { get; set; } = "";
public string Version { get; set; } = "";
public string Timestamp { get; set; } = "";
}
}