-
Notifications
You must be signed in to change notification settings - Fork 160
Expand file tree
/
Copy pathAppTelemetry.cs
More file actions
301 lines (268 loc) · 9.86 KB
/
Copy pathAppTelemetry.cs
File metadata and controls
301 lines (268 loc) · 9.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using Sentry;
namespace BurrowWin.Services;
/// Static telemetry facade for the Windows app — crash reporting (Sentry) plus
/// minimal, anonymous product analytics (PostHog over plain HTTP). It mirrors
/// the macOS app's privacy posture (opt-out, no PII, IP stripped, anonymous
/// random id) but reports to SEPARATE Windows-only projects, so nothing on the
/// macOS side changes and no platform discriminator flag is required.
///
/// Two independent gates guard every call:
/// 1. the user's opt-out preference (`BurrowSettings.TelemetryEnabled`), and
/// 2. whether a DSN / API key is actually configured (see TelemetryConfig).
/// Local/dev builds configure neither, so this is wholly inert there.
///
/// PostHog is intentionally hand-rolled over HTTP rather than via a NuGet
/// client: it keeps the capture payload — and thus the privacy guarantees —
/// fully under our control, and adds no package whose API could drift.
public static class AppTelemetry
{
private static readonly object Sync = new();
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) };
private static bool _enabled;
private static bool _sentryStarted;
private static string _distinctId = string.Empty;
/// Keys that must never leave the device, dropped from any event payload.
private static readonly HashSet<string> BlockedKeys = new(StringComparer.OrdinalIgnoreCase)
{
"api_key", "token", "authorization", "password", "secret",
"file_path", "path", "url", "home", "home_dir", "username",
"user", "email", "clipboard", "file_name", "contents",
};
/// Bring telemetry up at launch with the user's stored preference. Safe to
/// call once; subsequent preference changes go through <see cref="SetEnabled"/>.
public static void Initialize(bool enabled)
{
lock (Sync)
{
_distinctId = ResolveDistinctId();
ApplyEnabled(enabled, coldStart: true);
}
}
/// React to the user flipping the opt-out toggle: start or tear down both
/// SDKs immediately so the preference takes effect without a relaunch.
public static void SetEnabled(bool enabled)
{
lock (Sync)
{
if (enabled == _enabled)
{
return;
}
ApplyEnabled(enabled, coldStart: false);
}
}
/// Report an unhandled exception (no-op unless enabled + Sentry configured).
public static void CaptureException(Exception exception, string phase)
{
lock (Sync)
{
if (!_enabled || !_sentryStarted)
{
return;
}
}
try
{
SentrySdk.CaptureException(exception);
}
catch
{
// Crash reporting must never itself crash the app.
}
}
/// Capture a product-analytics event (no-op unless enabled + PostHog
/// configured). Properties are sanitized and merged with anonymous device
/// context before send. Fire-and-forget; never throws.
public static void Capture(string eventName, Dictionary<string, object>? properties = null)
{
string apiKey, host, distinctId;
lock (Sync)
{
if (!_enabled || !TelemetryConfig.IsPostHogConfigured)
{
return;
}
apiKey = TelemetryConfig.PostHogApiKey;
host = TelemetryConfig.PostHogHost;
distinctId = _distinctId;
}
var payload = new Dictionary<string, object?>
{
["api_key"] = apiKey,
["event"] = eventName,
["distinct_id"] = distinctId,
["timestamp"] = DateTime.UtcNow.ToString("o"),
["properties"] = BuildProperties(properties),
};
_ = PostAsync(host, payload);
}
private static void ApplyEnabled(bool enabled, bool coldStart)
{
_enabled = enabled;
if (enabled)
{
StartSentry();
Capture(coldStart ? "app_opened" : "telemetry_opt_in_changed",
coldStart ? new Dictionary<string, object> { ["cold_start"] = true }
: new Dictionary<string, object> { ["enabled"] = true });
}
else
{
// Record the opt-out itself before muting (matches macOS), then close.
Capture("telemetry_opt_in_changed", new Dictionary<string, object> { ["enabled"] = false });
StopSentry();
}
}
private static void StartSentry()
{
if (_sentryStarted || !TelemetryConfig.IsSentryConfigured)
{
return;
}
SentrySdk.Init(options =>
{
options.Dsn = TelemetryConfig.SentryDsn;
options.Environment = "production";
options.Release = $"burrow-win@{AppInfo.Version}";
// Crash/error events only — no performance tracing, no PII, no
// per-launch session beacon, no auto breadcrumbs.
options.TracesSampleRate = 0.0;
options.SendDefaultPii = false;
options.AutoSessionTracking = false;
options.IsGlobalModeEnabled = true;
options.SetBeforeSend(static (SentryEvent sentryEvent, SentryHint _) =>
{
// The machine name can embed a real user name; never ship it.
sentryEvent.ServerName = null;
return sentryEvent;
});
});
_sentryStarted = true;
}
private static void StopSentry()
{
if (!_sentryStarted)
{
return;
}
try
{
SentrySdk.Close();
}
catch
{
// Best effort.
}
_sentryStarted = false;
}
private static Dictionary<string, object?> BuildProperties(Dictionary<string, object>? user)
{
var props = new Dictionary<string, object?>
{
// PostHog uses this in place of the connection IP, so no real IP is
// stored and GeoIP is skipped (belt-and-suspenders with the project's
// "Discard client IP data").
["$ip"] = "0",
["$lib"] = "burrow-win",
// Explicit discriminator: the Windows app shares the macOS PostHog
// project (free-plan 1-project limit), so dashboards filter on this.
["platform"] = "windows",
["app_version"] = AppInfo.Version,
["os_version"] = AppInfo.OsVersion,
["arch"] = AppInfo.Arch,
};
if (user is not null)
{
foreach (var (key, value) in Sanitize(user))
{
props[key] = value;
}
}
return props;
}
/// Defense in depth: drop known-sensitive keys, anything non-primitive, and
/// any string that looks like a user path.
private static Dictionary<string, object> Sanitize(Dictionary<string, object> input)
{
var output = new Dictionary<string, object>();
foreach (var (key, value) in input)
{
if (BlockedKeys.Contains(key))
{
continue;
}
switch (value)
{
case bool or int or long or double or float:
output[key] = value;
break;
case string text when !LooksLikeUserPath(text):
output[key] = text;
break;
// Everything else (objects, arrays, sensitive strings) is dropped.
}
}
return output;
}
private static bool LooksLikeUserPath(string value) =>
value.Contains(@"\Users\", StringComparison.OrdinalIgnoreCase) ||
value.Contains(":\\Users", StringComparison.OrdinalIgnoreCase);
private static async Task PostAsync(string host, Dictionary<string, object?> payload)
{
try
{
var url = host.TrimEnd('/') + "/capture/";
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await Http.PostAsync(url, content).ConfigureAwait(false);
}
catch
{
// Analytics is best-effort and must never surface an error.
}
}
/// A stable, anonymous per-install id — a random GUID persisted next to the
/// settings file. Never derived from hardware, serial, or account.
private static string ResolveDistinctId()
{
try
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"BurrowWin");
var path = Path.Combine(dir, "telemetry-id");
if (File.Exists(path))
{
var existing = File.ReadAllText(path).Trim();
if (Guid.TryParse(existing, out _))
{
return existing;
}
}
Directory.CreateDirectory(dir);
var id = Guid.NewGuid().ToString();
File.WriteAllText(path, id);
return id;
}
catch
{
// A fresh per-session id is an acceptable fallback if disk is unwritable.
return Guid.NewGuid().ToString();
}
}
}
/// Anonymous device/app context attached to telemetry events.
internal static class AppInfo
{
public static string Version =>
Environment.GetEnvironmentVariable("BURROWWIN_VERSION")
?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
?? "0.0.0";
public static string OsVersion => $"Windows {Environment.OSVersion.Version}";
public static string Arch => RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant();
}