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:
@@ -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";
|
||||
|
||||
|
||||
107
src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs
Normal file
107
src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user