mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
0.7.2
This commit is contained in:
File diff suppressed because it is too large
Load Diff
629
LanMountainDesktop/Services/PostHogUsageTelemetryService.cs
Normal file
629
LanMountainDesktop/Services/PostHogUsageTelemetryService.cs
Normal file
@@ -0,0 +1,629 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
{
|
||||
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||
private const string PostHogHost = "https://us.i.posthog.com/capture/";
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly HttpClient _httpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
private readonly Queue<TelemetryEvent> _eventQueue = new();
|
||||
private readonly object _queueLock = new();
|
||||
|
||||
private Timer? _flushTimer;
|
||||
private bool _isInitialized;
|
||||
private bool _isUsageEnabled;
|
||||
private bool _sessionActive;
|
||||
private string _sessionId = string.Empty;
|
||||
private DateTimeOffset _sessionStartUtc;
|
||||
private long _sequence;
|
||||
private readonly string _launchId = Guid.NewGuid().ToString("N");
|
||||
|
||||
public PostHogUsageTelemetryService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsService = settingsFacade.Settings;
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
public bool IsUsageEnabled => _isUsageEnabled;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
EnsureBaselineEventSent();
|
||||
RefreshEnabledState(forceSessionStart: true);
|
||||
|
||||
_flushTimer = new Timer(
|
||||
_ => FlushEvents(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
AppLogger.Info(
|
||||
"PostHogUsage",
|
||||
$"Usage telemetry initialized. Enabled={_isUsageEnabled}; InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}.");
|
||||
}
|
||||
|
||||
public void RefreshEnabledState(bool forceSessionStart = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var enabled = snapshot.UploadAnonymousUsageData;
|
||||
|
||||
if (_isUsageEnabled == enabled && !forceSessionStart)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = _isUsageEnabled;
|
||||
_isUsageEnabled = enabled;
|
||||
AppLogger.Info("PostHogUsage", $"Usage analytics enabled state changed from '{previous}' to '{_isUsageEnabled}'.");
|
||||
|
||||
if (_isUsageEnabled)
|
||||
{
|
||||
StartSession("usage_enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
ClearQueuedEvents();
|
||||
StopSessionWithoutSending();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to refresh usage analytics enabled state.", ex);
|
||||
_isUsageEnabled = false;
|
||||
ClearQueuedEvents();
|
||||
StopSessionWithoutSending();
|
||||
}
|
||||
}
|
||||
|
||||
public void TrackMainWindowOpened(string source, bool isVisible, string windowState)
|
||||
{
|
||||
CaptureEvent(
|
||||
"main_window_opened",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["is_visible"] = isVisible,
|
||||
["window_state"] = windowState
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackMainWindowClosed(string source, bool wasVisible, string windowState)
|
||||
{
|
||||
CaptureEvent(
|
||||
"main_window_closed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["was_visible"] = wasVisible,
|
||||
["window_state"] = windowState
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSettingsWindowOpened(string source, string? currentPageId)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_window_opened",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["current_page_id"] = currentPageId
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSettingsWindowClosed(string source, string? currentPageId)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_window_closed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["current_page_id"] = currentPageId
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSettingsNavigation(string? fromPageId, string? toPageId, string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_navigation",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["from_page_id"] = fromPageId,
|
||||
["to_page_id"] = toPageId
|
||||
},
|
||||
stateBefore: CreatePageState(fromPageId),
|
||||
stateAfter: CreatePageState(toPageId));
|
||||
}
|
||||
|
||||
public void TrackSettingsDrawerOpened(string? pageId, string? drawerTitle)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_drawer_opened",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["page_id"] = pageId,
|
||||
["drawer_title"] = drawerTitle
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSettingsDrawerClosed(string? pageId, string? drawerTitle)
|
||||
{
|
||||
CaptureEvent(
|
||||
"settings_drawer_closed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["page_id"] = pageId,
|
||||
["drawer_title"] = drawerTitle
|
||||
},
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentPlaced(DesktopComponentPlacementSnapshot placement, string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_placed",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source
|
||||
},
|
||||
stateAfter: DescribePlacement(placement),
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentMoved(
|
||||
DesktopComponentPlacementSnapshot before,
|
||||
DesktopComponentPlacementSnapshot after,
|
||||
string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_moved",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source
|
||||
},
|
||||
stateBefore: DescribePlacement(before),
|
||||
stateAfter: DescribePlacement(after),
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentResized(
|
||||
DesktopComponentPlacementSnapshot before,
|
||||
DesktopComponentPlacementSnapshot after,
|
||||
string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_resized",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source
|
||||
},
|
||||
stateBefore: DescribePlacement(before),
|
||||
stateAfter: DescribePlacement(after),
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentDeleted(DesktopComponentPlacementSnapshot before, string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_deleted",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source
|
||||
},
|
||||
stateBefore: DescribePlacement(before),
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackDesktopComponentEditorOpened(DesktopComponentPlacementSnapshot placement, string source)
|
||||
{
|
||||
CaptureEvent(
|
||||
"desktop_component_editor_opened",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source
|
||||
},
|
||||
stateBefore: DescribePlacement(placement),
|
||||
forceFlush: true);
|
||||
}
|
||||
|
||||
public void TrackSessionStarted(string source)
|
||||
{
|
||||
StartSession(source);
|
||||
}
|
||||
|
||||
public void TrackSessionEnded(string source)
|
||||
{
|
||||
EndSession(source);
|
||||
}
|
||||
|
||||
public void Shutdown(bool isRestart, string source)
|
||||
{
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isUsageEnabled && _sessionActive)
|
||||
{
|
||||
EndSession(source, isRestart);
|
||||
}
|
||||
|
||||
FlushEvents();
|
||||
AppLogger.Info(
|
||||
"PostHogUsage",
|
||||
$"Usage telemetry shutdown complete. Source='{source}'; Restart='{isRestart}'; Enabled={_isUsageEnabled}.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_flushTimer?.Dispose();
|
||||
_settingsService.Changed -= OnSettingsChanged;
|
||||
Shutdown(isRestart: false, source: "Dispose");
|
||||
FlushEvents();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Error disposing usage telemetry service.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureBaselineEventSent()
|
||||
{
|
||||
try
|
||||
{
|
||||
var identity = TelemetryIdentityService.Instance;
|
||||
if (identity.HasReportedBaseline)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (SendBaselineEventToPostHog(identity.InstallId, now))
|
||||
{
|
||||
identity.MarkBaselineReported();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool SendBaselineEventToPostHog(string installId, DateTimeOffset timestamp)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new Dictionary<string, object?>
|
||||
{
|
||||
["api_key"] = PostHogApiKey,
|
||||
["event"] = "app_first_launch",
|
||||
["distinct_id"] = installId,
|
||||
["timestamp"] = timestamp.ToString("o"),
|
||||
["properties"] = new Dictionary<string, object?>
|
||||
{
|
||||
["launch_time_utc"] = timestamp.ToString("o")
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
using var content = new ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PostHogUsage",
|
||||
$"PostHog baseline event failed: {response.StatusCode} - {responseBody}");
|
||||
return false;
|
||||
}
|
||||
|
||||
AppLogger.Info("PostHogUsage", "Sent first-launch baseline event.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartSession(string source)
|
||||
{
|
||||
if (!_isInitialized || !_isUsageEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sessionActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sessionActive = true;
|
||||
_sessionId = Guid.NewGuid().ToString("N");
|
||||
_sessionStartUtc = DateTimeOffset.UtcNow;
|
||||
_sequence = 0;
|
||||
|
||||
CaptureEvent(
|
||||
"app_session_start",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["launch_id"] = _launchId,
|
||||
["session_start_utc"] = _sessionStartUtc.ToString("o"),
|
||||
["local_hour"] = _sessionStartUtc.ToLocalTime().Hour,
|
||||
["day_part"] = TelemetryEnvironmentInfo.GetLocalDayPart(_sessionStartUtc),
|
||||
["timezone"] = TimeZoneInfo.Local.Id,
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture()
|
||||
},
|
||||
forceFlush: true);
|
||||
|
||||
AppLogger.Info("PostHogUsage", $"Session started. SessionId={_sessionId}; Source='{source}'.");
|
||||
}
|
||||
|
||||
private void EndSession(string source, bool isRestart = false)
|
||||
{
|
||||
if (!_isInitialized || !_sessionActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var endUtc = DateTimeOffset.UtcNow;
|
||||
var durationMs = Math.Max(0, (long)(endUtc - _sessionStartUtc).TotalMilliseconds);
|
||||
|
||||
CaptureEvent(
|
||||
"app_session_end",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = source,
|
||||
["launch_id"] = _launchId,
|
||||
["session_start_utc"] = _sessionStartUtc.ToString("o"),
|
||||
["session_end_utc"] = endUtc.ToString("o"),
|
||||
["duration_ms"] = durationMs,
|
||||
["is_restart"] = isRestart
|
||||
},
|
||||
forceFlush: true);
|
||||
|
||||
_sessionActive = false;
|
||||
_sessionId = string.Empty;
|
||||
_sessionStartUtc = default;
|
||||
_sequence = 0;
|
||||
AppLogger.Info("PostHogUsage", $"Session ended. Source='{source}'; DurationMs={durationMs}; Restart={isRestart}.");
|
||||
}
|
||||
|
||||
private void StopSessionWithoutSending()
|
||||
{
|
||||
_sessionActive = false;
|
||||
_sessionId = string.Empty;
|
||||
_sessionStartUtc = default;
|
||||
_sequence = 0;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||
{
|
||||
_ = sender;
|
||||
|
||||
if (e.Scope != SettingsScope.App ||
|
||||
e.ChangedKeys is null ||
|
||||
!e.ChangedKeys.Contains(nameof(AppSettingsSnapshot.UploadAnonymousUsageData), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("PostHogUsage", "Usage analytics settings changed. Refreshing enabled state.");
|
||||
RefreshEnabledState();
|
||||
}
|
||||
|
||||
private void CaptureEvent(
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, object?>? payload = null,
|
||||
IReadOnlyDictionary<string, object?>? stateBefore = null,
|
||||
IReadOnlyDictionary<string, object?>? stateAfter = null,
|
||||
bool forceFlush = false)
|
||||
{
|
||||
if (!_isInitialized || !_isUsageEnabled || !_sessionActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var eventData = new TelemetryEvent(
|
||||
eventName,
|
||||
TelemetryIdentityService.Instance.TelemetryId,
|
||||
TelemetryIdentityService.Instance.InstallId,
|
||||
TelemetryIdentityService.Instance.TelemetryId,
|
||||
_sessionId,
|
||||
Interlocked.Increment(ref _sequence),
|
||||
DateTimeOffset.UtcNow,
|
||||
payload ?? new Dictionary<string, object?>(),
|
||||
stateBefore,
|
||||
stateAfter);
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
_eventQueue.Enqueue(eventData);
|
||||
}
|
||||
|
||||
if (forceFlush)
|
||||
{
|
||||
FlushEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldFlush = false;
|
||||
lock (_queueLock)
|
||||
{
|
||||
shouldFlush = _eventQueue.Count >= 20;
|
||||
}
|
||||
|
||||
if (shouldFlush)
|
||||
{
|
||||
FlushEvents();
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushEvents()
|
||||
{
|
||||
List<TelemetryEvent> eventsToSend;
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (_eventQueue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
eventsToSend = new List<TelemetryEvent>();
|
||||
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
|
||||
{
|
||||
eventsToSend.Add(_eventQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var telemetryEvent in eventsToSend)
|
||||
{
|
||||
if (!SendEventToPostHog(telemetryEvent, flushImmediately: false))
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to send PostHog event '{telemetryEvent.EventName}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send queued events to PostHog.", ex);
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
foreach (var evt in eventsToSend)
|
||||
{
|
||||
if (_eventQueue.Count >= 100)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_eventQueue.Enqueue(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool SendEventToPostHog(TelemetryEvent telemetryEvent, bool flushImmediately)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new Dictionary<string, object?>
|
||||
{
|
||||
["api_key"] = PostHogApiKey,
|
||||
["event"] = telemetryEvent.EventName,
|
||||
["distinct_id"] = telemetryEvent.DistinctId,
|
||||
["timestamp"] = telemetryEvent.Timestamp.ToString("o"),
|
||||
["properties"] = telemetryEvent.ToPostHogProperties()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
using var content = new ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PostHogUsage",
|
||||
$"PostHog event '{telemetryEvent.EventName}' failed: {response.StatusCode} - {responseBody}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flushImmediately)
|
||||
{
|
||||
AppLogger.Info("PostHogUsage", $"Sent event '{telemetryEvent.EventName}' immediately.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", $"Failed to send PostHog event '{telemetryEvent.EventName}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearQueuedEvents()
|
||||
{
|
||||
lock (_queueLock)
|
||||
{
|
||||
_eventQueue.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> CreatePageState(string? pageId)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["page_id"] = pageId
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> DescribePlacement(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["placement_id"] = placement.PlacementId,
|
||||
["component_id"] = placement.ComponentId,
|
||||
["page_index"] = placement.PageIndex,
|
||||
["row"] = placement.Row,
|
||||
["column"] = placement.Column,
|
||||
["width_cells"] = placement.WidthCells,
|
||||
["height_cells"] = placement.HeightCells
|
||||
};
|
||||
}
|
||||
}
|
||||
410
LanMountainDesktop/Services/SentryCrashTelemetryService.cs
Normal file
410
LanMountainDesktop/Services/SentryCrashTelemetryService.cs
Normal file
@@ -0,0 +1,410 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Sentry;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class SentryCrashTelemetryService : IDisposable
|
||||
{
|
||||
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||
private const string AutoIpAddress = "{{auto}}";
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly object _syncRoot = new();
|
||||
|
||||
private IDisposable? _sentryHandle;
|
||||
private bool _isInitialized;
|
||||
private bool _isEnabled;
|
||||
private bool _disposed;
|
||||
|
||||
public SentryCrashTelemetryService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsService = settingsFacade.Settings;
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _isInitialized && _isEnabled && SentrySdk.IsEnabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
RefreshEnabledState(force: true);
|
||||
}
|
||||
|
||||
public void RefreshEnabledState(bool force = false)
|
||||
{
|
||||
bool shouldEnable;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
shouldEnable = snapshot.UploadAnonymousCrashData;
|
||||
|
||||
if (!force && _isEnabled == shouldEnable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldEnable)
|
||||
{
|
||||
EnableSentry();
|
||||
return;
|
||||
}
|
||||
|
||||
DisableSentry();
|
||||
}
|
||||
|
||||
public void CaptureUnhandledException(Exception exception, string source, bool isTerminating)
|
||||
{
|
||||
if (exception is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!CanCapture())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var eventId = SentrySdk.CaptureException(exception, scope =>
|
||||
{
|
||||
ApplyCommonScope(scope, source, "unhandled_exception", includeLogTail: true);
|
||||
scope.Level = isTerminating ? SentryLevel.Fatal : SentryLevel.Error;
|
||||
scope.SetTag("exception_source", source);
|
||||
scope.SetTag("is_terminating", isTerminating.ToString());
|
||||
});
|
||||
|
||||
AppLogger.Info("SentryCrash", $"Captured unhandled exception from '{source}'. EventId={eventId}.");
|
||||
|
||||
if (isTerminating)
|
||||
{
|
||||
EndCrashSession();
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
public void CaptureTaskException(Exception exception, string source)
|
||||
{
|
||||
if (exception is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!CanCapture())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var eventId = SentrySdk.CaptureException(exception, scope =>
|
||||
{
|
||||
ApplyCommonScope(scope, source, "task_exception", includeLogTail: true);
|
||||
scope.Level = SentryLevel.Error;
|
||||
scope.SetTag("exception_source", source);
|
||||
});
|
||||
|
||||
AppLogger.Info("SentryCrash", $"Captured task exception from '{source}'. EventId={eventId}.");
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
public void CaptureShutdown(bool isRestart, string source)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!CanCapture())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var eventId = SentrySdk.CaptureMessage("application_shutdown", scope =>
|
||||
{
|
||||
ApplyCommonScope(scope, source, "shutdown", includeLogTail: true);
|
||||
scope.Level = SentryLevel.Info;
|
||||
scope.SetTag("shutdown_intent", isRestart ? "restart" : "exit");
|
||||
scope.SetExtra("shutdown_intent", isRestart ? "restart" : "exit");
|
||||
}, SentryLevel.Info);
|
||||
|
||||
AppLogger.Info(
|
||||
"SentryCrash",
|
||||
$"Captured application shutdown. Source='{source}'; Restart={isRestart}; EventId={eventId}.");
|
||||
|
||||
EndCrashSession();
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_settingsService.Changed -= OnSettingsChanged;
|
||||
DisableSentry();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SentryCrash", "Failed to dispose crash telemetry service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnableSentry()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_isEnabled && _sentryHandle is not null && SentrySdk.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var handle = SentrySdk.Init(options =>
|
||||
{
|
||||
options.Dsn = SentryDsn;
|
||||
options.AutoSessionTracking = true;
|
||||
options.AttachStacktrace = true;
|
||||
options.SendDefaultPii = true;
|
||||
options.MaxBreadcrumbs = 100;
|
||||
options.Release = TelemetryEnvironmentInfo.GetAppVersion();
|
||||
options.Environment = TelemetryEnvironmentInfo.GetEnvironment();
|
||||
options.DisableAppDomainUnhandledExceptionCapture();
|
||||
options.DisableUnobservedTaskExceptionCapture();
|
||||
});
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
handle.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
_sentryHandle?.Dispose();
|
||||
_sentryHandle = handle;
|
||||
_isEnabled = true;
|
||||
}
|
||||
|
||||
SentrySdk.ConfigureScope(scope => ApplyCommonScope(scope, "startup", "startup", includeLogTail: false));
|
||||
AppLogger.Info("SentryCrash", "Crash telemetry enabled.");
|
||||
}
|
||||
|
||||
private void DisableSentry()
|
||||
{
|
||||
IDisposable? handle;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!_isEnabled && _sentryHandle is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isEnabled = false;
|
||||
handle = _sentryHandle;
|
||||
_sentryHandle = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EndCrashSession();
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SentryCrash", "Failed to flush Sentry while disabling crash telemetry.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle?.Dispose();
|
||||
}
|
||||
|
||||
AppLogger.Info("SentryCrash", "Crash telemetry disabled.");
|
||||
}
|
||||
|
||||
private void EndCrashSession()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (SentrySdk.IsEnabled)
|
||||
{
|
||||
SentrySdk.EndSession(SessionEndStatus.Exited);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SentryCrash", "Failed to end Sentry session.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCapture()
|
||||
{
|
||||
return !_disposed && _isInitialized && _isEnabled && SentrySdk.IsEnabled;
|
||||
}
|
||||
|
||||
private void ApplyCommonScope(Scope scope, string source, string eventType, bool includeLogTail)
|
||||
{
|
||||
var installId = TelemetryIdentityService.Instance.InstallId;
|
||||
var telemetryId = TelemetryIdentityService.Instance.TelemetryId;
|
||||
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = telemetryId,
|
||||
IpAddress = AutoIpAddress
|
||||
};
|
||||
|
||||
scope.SetTag("telemetry_channel", "sentry");
|
||||
scope.SetTag("event_type", eventType);
|
||||
scope.SetTag("source", source);
|
||||
scope.SetTag("install_id", installId);
|
||||
scope.SetTag("telemetry_id", telemetryId);
|
||||
scope.SetTag("app_version", TelemetryEnvironmentInfo.GetAppVersion());
|
||||
scope.SetTag("environment", TelemetryEnvironmentInfo.GetEnvironment());
|
||||
scope.SetTag("os_name", TelemetryEnvironmentInfo.GetOsName());
|
||||
scope.SetTag("os_version", TelemetryEnvironmentInfo.GetOsVersion());
|
||||
scope.SetTag("os_build", TelemetryEnvironmentInfo.GetOsBuild());
|
||||
scope.SetTag("device_model", TelemetryEnvironmentInfo.GetDeviceModel());
|
||||
scope.SetTag("device_arch", TelemetryEnvironmentInfo.GetDeviceArchitecture());
|
||||
scope.SetTag("processor_count", TelemetryEnvironmentInfo.GetProcessorCount().ToString());
|
||||
scope.SetTag("total_memory_mb", TelemetryEnvironmentInfo.GetTotalMemoryMB().ToString());
|
||||
scope.SetTag("runtime_version", TelemetryEnvironmentInfo.GetRuntimeVersion());
|
||||
scope.SetTag("clr_version", TelemetryEnvironmentInfo.GetClrVersion());
|
||||
scope.SetTag("language", TelemetryEnvironmentInfo.GetSystemLanguage());
|
||||
scope.SetExtra("install_id", installId);
|
||||
scope.SetExtra("telemetry_id", telemetryId);
|
||||
scope.SetExtra("app_version", TelemetryEnvironmentInfo.GetAppVersion());
|
||||
scope.SetExtra("environment", TelemetryEnvironmentInfo.GetEnvironment());
|
||||
scope.SetExtra("os_name", TelemetryEnvironmentInfo.GetOsName());
|
||||
scope.SetExtra("os_version", TelemetryEnvironmentInfo.GetOsVersion());
|
||||
scope.SetExtra("os_build", TelemetryEnvironmentInfo.GetOsBuild());
|
||||
scope.SetExtra("device_model", TelemetryEnvironmentInfo.GetDeviceModel());
|
||||
scope.SetExtra("device_arch", TelemetryEnvironmentInfo.GetDeviceArchitecture());
|
||||
scope.SetExtra("processor_count", TelemetryEnvironmentInfo.GetProcessorCount());
|
||||
scope.SetExtra("total_memory_mb", TelemetryEnvironmentInfo.GetTotalMemoryMB());
|
||||
scope.SetExtra("runtime_version", TelemetryEnvironmentInfo.GetRuntimeVersion());
|
||||
scope.SetExtra("clr_version", TelemetryEnvironmentInfo.GetClrVersion());
|
||||
scope.SetExtra("language", TelemetryEnvironmentInfo.GetSystemLanguage());
|
||||
scope.SetExtra("log_file_path", AppLogger.LogFilePath);
|
||||
|
||||
if (includeLogTail)
|
||||
{
|
||||
var logTail = ReadLogTail(maxLines: 200, maxCharacters: 32_768);
|
||||
if (!string.IsNullOrWhiteSpace(logTail))
|
||||
{
|
||||
scope.SetExtra("log_tail", logTail);
|
||||
scope.SetExtra("log_tail_line_count", logTail.Count(character => character == '\n') + 1);
|
||||
var attachment = new Attachment(
|
||||
AttachmentType.Default,
|
||||
new ByteAttachmentContent(Encoding.UTF8.GetBytes(logTail)),
|
||||
"log-tail.txt",
|
||||
"text/plain");
|
||||
scope.AddAttachment(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||
{
|
||||
_ = sender;
|
||||
|
||||
if (e.Scope != SettingsScope.App ||
|
||||
e.ChangedKeys is null ||
|
||||
!e.ChangedKeys.Contains(nameof(AppSettingsSnapshot.UploadAnonymousCrashData), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("SentryCrash", "Crash telemetry setting changed. Refreshing enabled state.");
|
||||
RefreshEnabledState();
|
||||
}
|
||||
|
||||
private static string ReadLogTail(int maxLines, int maxCharacters)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFilePath = AppLogger.LogFilePath;
|
||||
if (string.IsNullOrWhiteSpace(logFilePath) || !File.Exists(logFilePath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var lines = new Queue<string>(Math.Min(maxLines, 256));
|
||||
using var reader = File.OpenText(logFilePath);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
if (lines.Count >= maxLines)
|
||||
{
|
||||
lines.Dequeue();
|
||||
}
|
||||
|
||||
lines.Enqueue(line);
|
||||
}
|
||||
|
||||
var tail = string.Join(Environment.NewLine, lines);
|
||||
if (tail.Length <= maxCharacters)
|
||||
{
|
||||
return tail;
|
||||
}
|
||||
|
||||
return tail[^maxCharacters..];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SentryCrash", "Failed to read log tail for crash telemetry.", ex);
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureNotDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SentryCrashTelemetryService));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,17 +609,32 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
|
||||
public void Save(PrivacySettingsState state)
|
||||
{
|
||||
var snapshot = _settingsService.Load();
|
||||
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
||||
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
||||
AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
|
||||
var changedKeys = new List<string>();
|
||||
|
||||
if (snapshot.UploadAnonymousCrashData != state.UploadAnonymousCrashData)
|
||||
{
|
||||
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.UploadAnonymousCrashData));
|
||||
}
|
||||
|
||||
if (snapshot.UploadAnonymousUsageData != state.UploadAnonymousUsageData)
|
||||
{
|
||||
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.UploadAnonymousUsageData));
|
||||
}
|
||||
|
||||
if (changedKeys.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"PrivacySettings",
|
||||
$"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys:
|
||||
[
|
||||
nameof(AppSettingsSnapshot.UploadAnonymousCrashData),
|
||||
nameof(AppSettingsSnapshot.UploadAnonymousUsageData)
|
||||
]);
|
||||
changedKeys: changedKeys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
144
LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs
Normal file
144
LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class TelemetryEnvironmentInfo
|
||||
{
|
||||
public static string GetAppVersion()
|
||||
{
|
||||
var assembly = typeof(TelemetryEnvironmentInfo).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||
}
|
||||
|
||||
public static string GetEnvironment()
|
||||
{
|
||||
#if DEBUG
|
||||
return "development";
|
||||
#else
|
||||
return "production";
|
||||
#endif
|
||||
}
|
||||
|
||||
public static string GetOsName()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return "Windows";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return "Linux";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
return "macOS";
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
public static string GetOsVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Environment.OSVersion.VersionString ?? "Unknown";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetOsBuild()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Environment.OSVersion.Version.Build.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetDeviceModel()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return "Windows PC";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
return "Linux PC";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
return "Mac";
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
public static string GetDeviceArchitecture()
|
||||
{
|
||||
return RuntimeInformation.OSArchitecture.ToString();
|
||||
}
|
||||
|
||||
public static string GetSystemLanguage()
|
||||
{
|
||||
try
|
||||
{
|
||||
return CultureInfo.CurrentUICulture.Name ?? "en-US";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "en-US";
|
||||
}
|
||||
}
|
||||
|
||||
public static int GetProcessorCount()
|
||||
{
|
||||
return Environment.ProcessorCount;
|
||||
}
|
||||
|
||||
public static long GetTotalMemoryMB()
|
||||
{
|
||||
try
|
||||
{
|
||||
return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetRuntimeVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
public static string GetClrVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
public static string GetLocalDayPart(DateTimeOffset timestamp)
|
||||
{
|
||||
var hour = timestamp.ToLocalTime().Hour;
|
||||
return hour switch
|
||||
{
|
||||
< 6 => "late_night",
|
||||
< 12 => "morning",
|
||||
< 18 => "afternoon",
|
||||
_ => "evening"
|
||||
};
|
||||
}
|
||||
}
|
||||
55
LanMountainDesktop/Services/TelemetryEvent.cs
Normal file
55
LanMountainDesktop/Services/TelemetryEvent.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed record TelemetryEvent(
|
||||
string EventName,
|
||||
string DistinctId,
|
||||
string InstallId,
|
||||
string TelemetryId,
|
||||
string SessionId,
|
||||
long Sequence,
|
||||
DateTimeOffset Timestamp,
|
||||
IReadOnlyDictionary<string, object?> Payload,
|
||||
IReadOnlyDictionary<string, object?>? StateBefore = null,
|
||||
IReadOnlyDictionary<string, object?>? StateAfter = null)
|
||||
{
|
||||
public Dictionary<string, object?> ToPostHogProperties()
|
||||
{
|
||||
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["install_id"] = InstallId,
|
||||
["telemetry_id"] = TelemetryId,
|
||||
["session_id"] = SessionId,
|
||||
["sequence"] = Sequence,
|
||||
["timestamp_utc"] = Timestamp.ToString("o"),
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
|
||||
["payload"] = Copy(Payload)
|
||||
};
|
||||
|
||||
if (StateBefore is not null && StateBefore.Count > 0)
|
||||
{
|
||||
properties["state_before"] = Copy(StateBefore);
|
||||
}
|
||||
|
||||
if (StateAfter is not null && StateAfter.Count > 0)
|
||||
{
|
||||
properties["state_after"] = Copy(StateAfter);
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> Copy(IReadOnlyDictionary<string, object?> source)
|
||||
{
|
||||
return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
177
LanMountainDesktop/Services/TelemetryIdentityService.cs
Normal file
177
LanMountainDesktop/Services/TelemetryIdentityService.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class TelemetryIdentityService
|
||||
{
|
||||
private static TelemetryIdentityService? _instance;
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly object _syncRoot = new();
|
||||
|
||||
private string _installId = string.Empty;
|
||||
private string _telemetryId = string.Empty;
|
||||
private bool _hasReportedBaseline;
|
||||
|
||||
public static TelemetryIdentityService Instance =>
|
||||
_instance ?? throw new InvalidOperationException("TelemetryIdentityService not initialized.");
|
||||
|
||||
private TelemetryIdentityService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
}
|
||||
|
||||
public static void Initialize(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
if (_instance is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var instance = new TelemetryIdentityService(settingsFacade);
|
||||
instance.LoadOrCreateIdentity();
|
||||
_instance = instance;
|
||||
TelemetryServices.Identity = instance;
|
||||
|
||||
AppLogger.Info(
|
||||
"TelemetryIdentity",
|
||||
$"Initialized. InstallId={instance.InstallId}; TelemetryId={instance.TelemetryId}; BaselineReported={instance.HasReportedBaseline}.");
|
||||
}
|
||||
|
||||
public string InstallId
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
EnsureInitialized();
|
||||
return _installId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string TelemetryId
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
EnsureInitialized();
|
||||
return _telemetryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasReportedBaseline
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
EnsureInitialized();
|
||||
return _hasReportedBaseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string RefreshTelemetryId()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
snapshot.TelemetryId = GenerateId();
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.TelemetryId)]);
|
||||
|
||||
_telemetryId = snapshot.TelemetryId ?? GenerateId();
|
||||
AppLogger.Info("TelemetryIdentity", $"Telemetry id refreshed. TelemetryId={_telemetryId}");
|
||||
return _telemetryId;
|
||||
}
|
||||
}
|
||||
|
||||
public bool MarkBaselineReported()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
if (_hasReportedBaseline)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
if (snapshot.HasReportedTelemetryBaseline)
|
||||
{
|
||||
_hasReportedBaseline = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot.HasReportedTelemetryBaseline = true;
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.HasReportedTelemetryBaseline)]);
|
||||
|
||||
_hasReportedBaseline = true;
|
||||
AppLogger.Info("TelemetryIdentity", "Marked baseline telemetry as reported.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadOrCreateIdentity()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var changedKeys = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(snapshot.TelemetryInstallId))
|
||||
{
|
||||
snapshot.TelemetryInstallId = GenerateId();
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.TelemetryInstallId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(snapshot.TelemetryId))
|
||||
{
|
||||
snapshot.TelemetryId = GenerateId();
|
||||
changedKeys.Add(nameof(AppSettingsSnapshot.TelemetryId));
|
||||
}
|
||||
|
||||
_installId = snapshot.TelemetryInstallId ?? GenerateId();
|
||||
_telemetryId = snapshot.TelemetryId ?? GenerateId();
|
||||
_hasReportedBaseline = snapshot.HasReportedTelemetryBaseline;
|
||||
|
||||
if (changedKeys.Count > 0)
|
||||
{
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys: changedKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureInitialized()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_installId) && !string.IsNullOrWhiteSpace(_telemetryId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadOrCreateIdentity();
|
||||
}
|
||||
|
||||
private static string GenerateId()
|
||||
{
|
||||
return Guid.NewGuid().ToString("N");
|
||||
}
|
||||
}
|
||||
10
LanMountainDesktop/Services/TelemetryServices.cs
Normal file
10
LanMountainDesktop/Services/TelemetryServices.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class TelemetryServices
|
||||
{
|
||||
public static TelemetryIdentityService? Identity { get; set; }
|
||||
|
||||
public static PostHogUsageTelemetryService? Usage { get; set; }
|
||||
|
||||
public static SentryCrashTelemetryService? Crash { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user