Skip to content

Commit 16adffa

Browse files
committed
GDPR compliance analytics update.
1 parent ca8a72a commit 16adffa

8 files changed

Lines changed: 458 additions & 7 deletions

File tree

‎Torch.API/ITorchConfig.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ public interface ITorchConfig
4545
List<ulong> Whitelist { get; set; }
4646
bool OverwriteGlobalNLogConfigOnUpdate { get; set; }
4747

48+
// Analytics — no PII
49+
bool EnableAnalytics { get; set; }
50+
string AnalyticsToken { get; set; }
51+
4852
void Save(string path = null);
4953
}
5054
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.Net.Http;
5+
using System.Text;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using NLog;
9+
using Sandbox.Game;
10+
using Torch.API;
11+
using Torch.API.Managers;
12+
using Torch.API.Session;
13+
using Torch.Managers;
14+
using Newtonsoft.Json;
15+
16+
namespace Torch.Server.Managers
17+
{
18+
/// <summary>
19+
/// Sends anonymous, GDPR-compliant usage telemetry to torchapi.com every 5 minutes.
20+
/// Only active when <see cref="TorchConfig.EnableAnalytics"/> is true (opt-in, default false).
21+
///
22+
/// Data sent: server name (on registration only), player count, uptime seconds,
23+
/// sim speed, Torch version, SE version, active plugin GUIDs. No PII is collected.
24+
/// Plugin GUIDs are public identifiers — they are the same GUIDs listed on the Torch
25+
/// plugin marketplace and do not identify individual users or servers.
26+
///
27+
/// A random UUID token is auto-generated on first registration and stored in Torch.cfg.
28+
/// To erase all stored data, send DELETE https://torchapi.com/analytics/server/{token}.
29+
/// </summary>
30+
public class AnalyticsManager : Manager
31+
{
32+
private static readonly Logger _log = LogManager.GetCurrentClassLogger();
33+
34+
private CancellationTokenSource _cts;
35+
36+
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
37+
private static readonly TimeSpan CooldownPeriod = TimeSpan.FromMinutes(30);
38+
private const int MaxConsecutiveFailures = 5;
39+
40+
private int _consecutiveFailures;
41+
private DateTime? _cooldownUntil;
42+
43+
private const string RegisterUrl = "https://torchapi.com/analytics/register";
44+
private const string ReportUrl = "https://torchapi.com/analytics/report";
45+
46+
private static readonly HttpClient _http = new HttpClient
47+
{
48+
Timeout = TimeSpan.FromSeconds(10)
49+
};
50+
51+
public AnalyticsManager(ITorchBase torchInstance) : base(torchInstance) { }
52+
53+
public override void Attach()
54+
{
55+
base.Attach();
56+
57+
if (!((TorchConfig)Torch.Config).EnableAnalytics)
58+
{
59+
_log.Debug("Analytics disabled — skipping (set EnableAnalytics=true in Torch.cfg to opt in).");
60+
return;
61+
}
62+
63+
_log.Info("=======================================================");
64+
_log.Info(" TORCH ANALYTICS — ENABLED");
65+
_log.Info(" Torch is reporting anonymous usage data to torchapi.com");
66+
_log.Info(" every 5 minutes. Data sent: player count, uptime,");
67+
_log.Info(" sim speed, Torch version, SE version,");
68+
_log.Info(" active plugin GUIDs (public identifiers, no PII).");
69+
_log.Info(" No player names, Steam IDs, IPs, or any PII collected.");
70+
_log.Info(" Privacy policy : https://torchapi.com/privacy");
71+
_log.Info(" To opt out : set EnableAnalytics=false in Torch.cfg");
72+
_log.Info("=======================================================");
73+
74+
_cts = new CancellationTokenSource();
75+
Task.Run(() => RunLoop(_cts.Token), _cts.Token);
76+
}
77+
78+
public override void Detach()
79+
{
80+
_cts?.Cancel();
81+
base.Detach();
82+
}
83+
84+
private async Task RunLoop(CancellationToken ct)
85+
{
86+
try
87+
{
88+
// Jitter: spread registrations/reports when many servers restart together
89+
// after an update. Random delay 0–60 s so servers don't all hit the
90+
// endpoint at the same millisecond.
91+
var jitter = TimeSpan.FromSeconds(new Random().Next(0, 60));
92+
_log.Debug($"Analytics starting in {jitter.TotalSeconds:F0}s (startup jitter).");
93+
await Task.Delay(jitter, ct);
94+
95+
// Self-register on first run (token is stored in Torch.cfg)
96+
if (string.IsNullOrEmpty(((TorchConfig)Torch.Config).AnalyticsToken))
97+
await Register(ct);
98+
99+
while (!ct.IsCancellationRequested)
100+
{
101+
await Report(ct);
102+
103+
try
104+
{
105+
await Task.Delay(Interval, ct);
106+
}
107+
catch (TaskCanceledException)
108+
{
109+
break;
110+
}
111+
}
112+
}
113+
catch (TaskCanceledException) { }
114+
catch (Exception ex)
115+
{
116+
_log.Warn($"Analytics loop error (non-fatal): {ex.Message}");
117+
}
118+
}
119+
120+
private async Task Register(CancellationToken ct)
121+
{
122+
try
123+
{
124+
_log.Info("Registering analytics token with torchapi.com…");
125+
126+
var payload = new { serverName = Torch.Config.InstanceName };
127+
var json = JsonConvert.SerializeObject(payload);
128+
var content = new StringContent(json, Encoding.UTF8, "application/json");
129+
130+
var resp = await _http.PostAsync(RegisterUrl, content, ct);
131+
if (!resp.IsSuccessStatusCode)
132+
{
133+
_log.Warn($"Analytics registration failed: HTTP {(int)resp.StatusCode}");
134+
return;
135+
}
136+
137+
var body = await resp.Content.ReadAsStringAsync();
138+
var data = JsonConvert.DeserializeObject<Dictionary<string, string>>(body);
139+
140+
if (data != null && data.TryGetValue("token", out var token))
141+
{
142+
((TorchConfig)Torch.Config).AnalyticsToken = token;
143+
// Auto-persisted to Torch.cfg via INotifyPropertyChanged
144+
_log.Info($"Analytics token registered: {token}");
145+
}
146+
else
147+
{
148+
_log.Warn("Analytics registration response missing token field.");
149+
}
150+
}
151+
catch (Exception ex)
152+
{
153+
_log.Warn($"Analytics registration error (non-fatal): {ex.Message}");
154+
}
155+
}
156+
157+
private async Task Report(CancellationToken ct)
158+
{
159+
try
160+
{
161+
// Check if we're in cooldown period
162+
if (_cooldownUntil.HasValue)
163+
{
164+
if (DateTime.UtcNow < _cooldownUntil.Value)
165+
{
166+
var remaining = _cooldownUntil.Value - DateTime.UtcNow;
167+
_log.Debug($"Analytics report skipped: in cooldown for {remaining.TotalMinutes:F1} more minutes after {MaxConsecutiveFailures} consecutive failures.");
168+
return;
169+
}
170+
171+
// Cooldown expired, reset and try again
172+
_log.Info("Analytics cooldown expired, resuming reports.");
173+
_cooldownUntil = null;
174+
_consecutiveFailures = 0;
175+
}
176+
177+
var token = ((TorchConfig)Torch.Config).AnalyticsToken;
178+
if (string.IsNullOrEmpty(token))
179+
{
180+
_log.Warn("Analytics report skipped: no token. Registration may have failed.");
181+
return;
182+
}
183+
184+
int playerCount = 0;
185+
float simSpeed = 0f;
186+
long uptimeSecs = (long)(DateTime.UtcNow - Process.GetCurrentProcess().StartTime.ToUniversalTime()).TotalSeconds;
187+
188+
var session = Torch.CurrentSession;
189+
if (session?.State == TorchSessionState.Loaded)
190+
{
191+
var mp = session.Managers.GetManager<MultiplayerManagerDedicated>();
192+
playerCount = mp?.Players.Count ?? 0;
193+
194+
// Read the live sim ratio from TorchServer
195+
if (TorchBase.Instance is TorchServer ts)
196+
simSpeed = ts.SimulationRatio;
197+
}
198+
199+
// Collect active plugin GUIDs — public identifiers only, no PII
200+
var pluginGuids = new List<string>();
201+
var pluginManager = Torch.Managers.GetManager<IPluginManager>();
202+
if (pluginManager != null)
203+
{
204+
foreach (var plugin in pluginManager.Plugins)
205+
pluginGuids.Add(plugin.Key.ToString());
206+
}
207+
208+
var payload = new
209+
{
210+
token,
211+
playerCount,
212+
uptimeSeconds = uptimeSecs,
213+
simSpeed,
214+
torchVersion = Torch.TorchVersion.ToString(),
215+
seVersion = MyPerGameSettings.BasicGameInfo.GameVersion.Value.ToString(),
216+
pluginGuids
217+
};
218+
219+
var json = JsonConvert.SerializeObject(payload);
220+
var content = new StringContent(json, Encoding.UTF8, "application/json");
221+
222+
var resp = await _http.PostAsync(ReportUrl, content, ct);
223+
224+
if (resp.IsSuccessStatusCode)
225+
{
226+
// Reset failure counter on success
227+
_consecutiveFailures = 0;
228+
_log.Debug($"Analytics report sent — players: {playerCount}, plugins: {pluginGuids.Count}, uptime: {uptimeSecs}s, sim: {simSpeed:F2} → HTTP {(int)resp.StatusCode}");
229+
}
230+
else
231+
{
232+
_consecutiveFailures++;
233+
_log.Warn($"Analytics report failed: HTTP {(int)resp.StatusCode} (failure {_consecutiveFailures}/{MaxConsecutiveFailures})");
234+
235+
if (_consecutiveFailures >= MaxConsecutiveFailures)
236+
{
237+
_cooldownUntil = DateTime.UtcNow.Add(CooldownPeriod);
238+
_log.Warn($"Analytics reports disabled for {CooldownPeriod.TotalMinutes} minutes after {MaxConsecutiveFailures} consecutive failures.");
239+
}
240+
}
241+
}
242+
catch (Exception ex)
243+
{
244+
_consecutiveFailures++;
245+
_log.Warn($"Analytics report error (non-fatal): {ex.Message} (failure {_consecutiveFailures}/{MaxConsecutiveFailures})");
246+
247+
if (_consecutiveFailures >= MaxConsecutiveFailures)
248+
{
249+
_cooldownUntil = DateTime.UtcNow.Add(CooldownPeriod);
250+
_log.Warn($"Analytics reports disabled for {CooldownPeriod.TotalMinutes} minutes after {MaxConsecutiveFailures} consecutive failures.");
251+
}
252+
}
253+
}
254+
}
255+
}

‎Torch.Server/Torch.Server.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@
244244
<Compile Include="Commands\WhitelistCommands.cs" />
245245
<Compile Include="FlowDocumentTarget.cs" />
246246
<Compile Include="ListBoxExtensions.cs" />
247+
<Compile Include="Managers\AnalyticsManager.cs" />
247248
<Compile Include="Managers\EntityControlManager.cs" />
248249
<Compile Include="Managers\GameUpdateManager.cs" />
249250
<Compile Include="Managers\MultiplayerManagerDedicated.cs" />

‎Torch.Server/TorchConfig.cs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ public class TorchConfig : CommandLine, ITorchConfig, INotifyPropertyChanged
5050
private bool _deleteMiniDumps = true;
5151
private string _loginToken;
5252
private bool bypassIsReloadableFlag;
53+
private bool _enableAnalytics = false;
54+
private string _analyticsToken = "";
5355

5456

5557
/// <inheritdoc />
@@ -127,6 +129,19 @@ public string InstancePath
127129
[Display(Name = "Bypass reloadable flag", Description = "Bypass the reloadable flag on plugins (forces true).", GroupName = "Server")]
128130
public bool BypassIsReloadableFlag { get => bypassIsReloadableFlag; set => Set(value, ref bypassIsReloadableFlag); }
129131

132+
/// <inheritdoc />
133+
[Display(Name = "Enable Analytics",
134+
Description = "Send anonymous usage telemetry to torchapi.com every 5 min. No PII collected. Default: false (opt-in).",
135+
GroupName = "Analytics")]
136+
public bool EnableAnalytics { get => _enableAnalytics; set => Set(value, ref _enableAnalytics); }
137+
138+
/// <inheritdoc />
139+
[Display(Name = "Analytics Token",
140+
Description = "Auto-generated UUID assigned on first registration. Stored in Torch.cfg. Do not share publicly.",
141+
GroupName = "Analytics",
142+
ReadOnly = true)]
143+
public string AnalyticsToken { get => _analyticsToken; set => Set(value, ref _analyticsToken); }
144+
130145
/// <inheritdoc />
131146
[Display(Name = "Watchdog Timeout", Description = "Watchdog timeout (in seconds).", GroupName = "Server")]
132147
public int TickTimeout { get => _tickTimeout; set => Set(value, ref _tickTimeout); }

‎Torch.Server/TorchServer.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ public TorchServer(TorchConfig config) : base(config)
6969
AddManager(new RemoteAPIManager(this));
7070
AddManager(new UpdateManager(this));
7171
AddManager(new GameUpdateManager(this));
72+
AddManager(new AnalyticsManager(this));
7273

7374
Managers.GetManager<UpdateManager>().CheckAndUpdateTorch();
7475

‎Torch.Server/Views/ConfigControl.xaml.cs‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33
using System.ComponentModel;
44
using System.Linq;
@@ -161,7 +161,21 @@ private void SetReadOnly()
161161
{
162162
foreach (var textbox in GetAllChildren<TextBox>(this))
163163
{
164-
textbox.IsReadOnly = !_server.CanRun;
164+
// Don't make inherently read-only textboxes editable
165+
// (e.g., AnalyticsToken with ReadOnly=true in DisplayAttribute)
166+
if (!_server.CanRun)
167+
textbox.IsReadOnly = true;
168+
// Only make editable if it wasn't marked as inherently read-only
169+
// We use Tag to track this - PropertyGrid doesn't set Tag on TextBoxes
170+
else if (textbox.Tag as string != "InherentlyReadOnly")
171+
{
172+
// Check if this is the first time and textbox is already read-only
173+
// If so, mark it as inherently read-only
174+
if (textbox.IsReadOnly && textbox.Tag == null)
175+
textbox.Tag = "InherentlyReadOnly";
176+
else
177+
textbox.IsReadOnly = false;
178+
}
165179
}
166180

167181
foreach (var button in GetAllChildren<Button>(this))

0 commit comments

Comments
 (0)