From 3d98ac4470f1f8055de161afe5e1e21916e147cd Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:05:49 +0200 Subject: [PATCH] telemetry: add opt-in client-applied reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/Metin2Launcher/Config/LauncherConfig.cs | 8 ++ .../Telemetry/ClientAppliedReporter.cs | 107 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs diff --git a/src/Metin2Launcher/Config/LauncherConfig.cs b/src/Metin2Launcher/Config/LauncherConfig.cs index 8125a43..2a407dc 100644 --- a/src/Metin2Launcher/Config/LauncherConfig.cs +++ b/src/Metin2Launcher/Config/LauncherConfig.cs @@ -30,6 +30,14 @@ public static class LauncherConfig public const int MaxBlobRetries = 3; + /// + /// 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 {format} and {version} placeholders, + /// both URL-escaped at send time. + /// + public const string TelemetryUrlTemplate = ""; + /// Name of the directory under the client root that holds launcher state. public const string StateDirName = ".updates"; diff --git a/src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs b/src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs new file mode 100644 index 0000000..45e1ffe --- /dev/null +++ b/src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs @@ -0,0 +1,107 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using Metin2Launcher.Logging; + +namespace Metin2Launcher.Telemetry; + +/// +/// 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 +/// 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 +/// +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; + } + + /// True when the reporter is actually wired to a destination. + public bool IsEnabled => !string.IsNullOrWhiteSpace(_urlTemplate); + + /// + /// Sends a single POST to the configured URL. Returns true if the + /// server accepted it (2xx), false on any other outcome. Never throws. + /// + public async Task 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; + } + } + + /// + /// Expands {format} and {version} placeholders in the URL + /// template. Case-sensitive, by design — we want obvious failures if the + /// template is wrong. + /// + 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; } = ""; + } +}