应用遥测,插件市场
This commit is contained in:
lincube
2026-03-16 09:50:48 +08:00
parent 557b79e8c0
commit 6c9f6be1b1
13 changed files with 1321 additions and 105 deletions

View File

@@ -0,0 +1,943 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using Sentry;
namespace LanMountainDesktop.Services;
public sealed class DeviceIdService
{
private static DeviceIdService? _instance;
private string? _deviceId;
private readonly ISettingsFacadeService _settingsFacade;
private bool _isInitialized;
public static DeviceIdService Instance => _instance ?? throw new InvalidOperationException("DeviceIdService not initialized");
public DeviceIdService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
}
public static void Initialize(ISettingsFacadeService settingsFacade)
{
_instance = new DeviceIdService(settingsFacade);
_instance.EnsureDeviceId();
}
public string DeviceId
{
get
{
if (_deviceId is null)
{
throw new InvalidOperationException("DeviceId not initialized");
}
return _deviceId;
}
}
private void EnsureDeviceId()
{
if (_isInitialized)
{
return;
}
_isInitialized = true;
try
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (string.IsNullOrEmpty(snapshot.DeviceId))
{
snapshot.DeviceId = GenerateDeviceId();
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]);
_deviceId = snapshot.DeviceId;
AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
}
else
{
_deviceId = snapshot.DeviceId;
AppLogger.Info("DeviceId", $"Loaded existing device ID: {_deviceId}");
}
}
catch (Exception ex)
{
_deviceId = GenerateDeviceId();
AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
}
}
private static string GenerateDeviceId()
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{timestamp}";
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
return Convert.ToHexString(hash)[..32].ToLower();
}
}
public sealed class UserBehaviorAnalyticsService : IDisposable
{
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
private const string PostHogHost = "https://us.i.posthog.com/capture/";
private bool _isEnabled;
private bool _isInitialized;
private readonly ISettingsFacadeService _settingsFacade;
private readonly DeviceIdService _deviceIdService;
private readonly Queue<UserBehaviorEvent> _eventQueue = new();
private readonly object _queueLock = new();
private System.Threading.Timer? _flushTimer;
private readonly PluginSdk.ISettingsService _settingsService;
public UserBehaviorAnalyticsService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_settingsService = settingsFacade.Settings;
_deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
_settingsService.Changed += OnSettingsChanged;
}
private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
{
if (e.Scope == PluginSdk.SettingsScope.App &&
e.ChangedKeys is not null &&
(e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
{
AppLogger.Info("UserBehaviorAnalytics", "Settings changed, refreshing enabled state.");
RefreshEnabledState();
}
}
public void Initialize()
{
if (_isInitialized)
{
return;
}
_isInitialized = true;
RefreshEnabledState();
try
{
_flushTimer = new System.Threading.Timer(
_ => FlushEvents(),
null,
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30));
CaptureEvent("app_online", new Dictionary<string, object>
{
{ "event_type", "app_start" }
});
AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Failed to initialize analytics.", ex);
}
}
public void TrackClick(string componentName, string? action = null)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("ui_click", new Dictionary<string, object>
{
{ "component", componentName },
{ "action", action ?? "click" }
});
}
public void TrackComponentDrag(string componentId, string action)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("component_drag", new Dictionary<string, object>
{
{ "component_id", componentId },
{ "action", action }
});
}
public void TrackComponentDrop(string componentId, string targetPosition)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("component_drop", new Dictionary<string, object>
{
{ "component_id", componentId },
{ "target_position", targetPosition }
});
}
public void TrackSettingsOpen(string settingsPage)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("settings_open", new Dictionary<string, object>
{
{ "page", settingsPage }
});
}
public void TrackSettingsChange(string settingsPage, string settingKey, string? oldValue, string newValue)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("settings_change", new Dictionary<string, object>
{
{ "page", settingsPage },
{ "key", settingKey },
{ "old_value", oldValue ?? "" },
{ "new_value", newValue }
});
}
public void TrackSettingsClose(string settingsPage)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("settings_close", new Dictionary<string, object>
{
{ "page", settingsPage }
});
}
public void TrackUpdateAction(string action, string? version = null)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
var props = new Dictionary<string, object>
{
{ "action", action }
};
if (version is not null)
{
props["version"] = version;
}
CaptureEvent("update_action", props);
}
public void TrackRestartAction(string action)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("restart_action", new Dictionary<string, object>
{
{ "action", action }
});
}
public void TrackNavigation(string fromPage, string toPage)
{
if (!_isEnabled || !_isInitialized)
{
return;
}
CaptureEvent("navigation", new Dictionary<string, object>
{
{ "from", fromPage },
{ "to", toPage }
});
}
public void SendCrashEvent()
{
if (!_isInitialized)
{
return;
}
try
{
var properties = new Dictionary<string, object>
{
{ "app_version", GetAppVersion() },
{ "event_time", DateTimeOffset.UtcNow.ToString("o") },
{ "event_type", "app_crash" }
};
CaptureEvent("app_crash", properties);
FlushEvents();
AppLogger.Info("UserBehaviorAnalytics", $"Crash event sent. DeviceId={_deviceIdService.DeviceId}");
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send crash event.", ex);
}
}
public void SendShutdownEvent()
{
if (!_isInitialized)
{
return;
}
try
{
var properties = new Dictionary<string, object>
{
{ "app_version", GetAppVersion() },
{ "event_time", DateTimeOffset.UtcNow.ToString("o") },
{ "event_type", "app_shutdown" }
};
if (_isEnabled)
{
properties["os_name"] = GetOsName();
properties["os_version"] = GetOsVersion();
properties["device_name"] = GetDeviceName();
properties["device_model"] = GetDeviceModel();
properties["device_arch"] = GetDeviceArchitecture();
properties["language"] = GetSystemLanguage();
}
CaptureEvent("app_shutdown", properties);
FlushEvents();
AppLogger.Info("UserBehaviorAnalytics", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send shutdown event.", ex);
}
}
public void RefreshEnabledState()
{
try
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var newEnabled = snapshot.UploadAnonymousUsageData;
if (_isEnabled != newEnabled)
{
_isEnabled = newEnabled;
AppLogger.Info("UserBehaviorAnalytics", $"User behavior analytics enabled state changed to '{_isEnabled}'.");
if (_isEnabled && _isInitialized)
{
CaptureEvent("analytics_enabled", new Dictionary<string, object>());
}
}
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Failed to refresh analytics enabled state.", ex);
_isEnabled = false;
}
}
public void CaptureEvent(string eventName, Dictionary<string, object>? properties = null)
{
if (!_isInitialized)
{
return;
}
try
{
var eventData = new UserBehaviorEvent
{
Event = eventName,
DistinctId = _deviceIdService.DeviceId,
Timestamp = DateTimeOffset.UtcNow,
Properties = properties ?? new Dictionary<string, object>(),
IncludeDetailedData = _isEnabled
};
lock (_queueLock)
{
_eventQueue.Enqueue(eventData);
if (_eventQueue.Count >= 20)
{
FlushEvents();
}
}
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", $"Failed to capture event '{eventName}'.", ex);
}
}
public void CapturePageView(string pageName, string? sourcePage = null)
{
var properties = new Dictionary<string, object>
{
{ "page_name", pageName }
};
if (!string.IsNullOrEmpty(sourcePage))
{
properties["source_page"] = sourcePage;
}
CaptureEvent("page_view", properties);
}
public void CaptureFeatureUsage(string featureName, string action)
{
CaptureEvent("feature_usage", new Dictionary<string, object>
{
{ "feature_name", featureName },
{ "action", action }
});
}
private void FlushEvents()
{
List<UserBehaviorEvent> eventsToSend;
lock (_queueLock)
{
if (_eventQueue.Count == 0)
{
return;
}
eventsToSend = new List<UserBehaviorEvent>();
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
{
eventsToSend.Add(_eventQueue.Dequeue());
}
}
try
{
SendEventsToPostHog(eventsToSend);
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog.", ex);
lock (_queueLock)
{
foreach (var evt in eventsToSend)
{
if (_eventQueue.Count < 100)
{
_eventQueue.Enqueue(evt);
}
}
}
}
}
private void SendEventsToPostHog(List<UserBehaviorEvent> events)
{
try
{
using var client = new System.Net.Http.HttpClient
{
Timeout = TimeSpan.FromSeconds(10)
};
var firstEvent = events.FirstOrDefault();
if (firstEvent is not null)
{
SendIdentifyToPostHog(client, firstEvent.DistinctId);
}
foreach (var e in events)
{
var properties = new Dictionary<string, object>
{
{ "distinct_id", e.DistinctId }
};
if (e.IncludeDetailedData)
{
properties["$os"] = GetOsName();
properties["$os_version"] = GetOsVersion();
properties["$app_version"] = GetAppVersion();
properties["$device_id"] = e.DistinctId;
}
foreach (var kvp in e.Properties)
{
properties[kvp.Key] = kvp.Value;
}
var requestBody = new Dictionary<string, object>
{
{ "api_key", PostHogApiKey },
{ "event", e.Event },
{ "timestamp", e.Timestamp.ToString("o") },
{ "properties", properties }
};
var json = JsonSerializer.Serialize(requestBody);
var bytes = Encoding.UTF8.GetBytes(json);
var content = new System.Net.Http.ByteArrayContent(bytes);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
{
AppLogger.Warn("UserBehaviorAnalytics", $"PostHog API error for event '{e.Event}': {response.StatusCode} - {responseBody}");
}
}
AppLogger.Info("UserBehaviorAnalytics", $"Successfully sent {events.Count} events to PostHog.");
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog API.", ex);
}
}
private void SendIdentifyToPostHog(System.Net.Http.HttpClient client, string distinctId)
{
try
{
var userProperties = new Dictionary<string, object>
{
{ "$device_id", distinctId },
{ "$app_version", GetAppVersion() },
{ "$os", GetOsName() },
{ "$os_version", GetOsVersion() }
};
var requestBody = new Dictionary<string, object>
{
{ "api_key", PostHogApiKey },
{ "event", "$identify" },
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") },
{ "properties", new Dictionary<string, object>
{
{ "distinct_id", distinctId },
{ "$set", userProperties }
}
}
};
var json = JsonSerializer.Serialize(requestBody);
var bytes = Encoding.UTF8.GetBytes(json);
var content = new System.Net.Http.ByteArrayContent(bytes);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
AppLogger.Info("UserBehaviorAnalytics", $"PostHog identify response: {response.StatusCode}");
if (!response.IsSuccessStatusCode)
{
AppLogger.Warn("UserBehaviorAnalytics", $"PostHog identify failed: {response.StatusCode} - {responseBody}");
}
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send identify to PostHog.", ex);
}
}
private static Dictionary<string, object> GetEventProperties(UserBehaviorEvent e)
{
var props = new Dictionary<string, object>
{
{ "$os", GetOsName() },
{ "$os_version", GetOsVersion() },
{ "$app_version", GetAppVersion() },
{ "$device_id", e.DistinctId }
};
foreach (var kvp in e.Properties)
{
props[kvp.Key] = kvp.Value;
}
return props;
}
public bool IsEnabled => _isEnabled;
public string DeviceId => _deviceIdService.DeviceId;
private static string GetAppVersion()
{
var assembly = typeof(UserBehaviorAnalyticsService).Assembly;
var version = assembly.GetName().Version;
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
}
private 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";
}
private static string GetOsVersion()
{
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
catch { return "Unknown"; }
}
private static string GetDeviceName()
{
try { return Environment.MachineName ?? "Unknown"; }
catch { return "Unknown"; }
}
private static string GetDeviceModel()
{
var osDesc = RuntimeInformation.OSDescription;
if (osDesc.Contains("Windows")) return "Windows PC";
if (osDesc.Contains("Linux")) return "Linux PC";
if (osDesc.Contains("Darwin")) return "Mac";
return osDesc;
}
private static string GetDeviceArchitecture()
{
return RuntimeInformation.OSArchitecture.ToString();
}
private static string GetSystemLanguage()
{
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
catch { return "en-US"; }
}
private static string GetOsBuild()
{
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
catch { return "Unknown"; }
}
private static int GetProcessorCount()
{
return Environment.ProcessorCount;
}
private static long GetTotalMemoryMB()
{
try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
catch { return 0; }
}
private static string GetRuntimeVersion()
{
return Environment.Version.ToString();
}
private static string GetClrVersion()
{
return Environment.Version.ToString();
}
private static string GetDotNetVersion()
{
return Environment.Version.ToString();
}
public void Dispose()
{
try
{
_flushTimer?.Dispose();
FlushEvents();
}
catch (Exception ex)
{
AppLogger.Warn("UserBehaviorAnalytics", "Error disposing analytics service.", ex);
}
}
private class UserBehaviorEvent
{
public string Event { get; set; } = string.Empty;
public string DistinctId { get; set; } = string.Empty;
public DateTimeOffset Timestamp { get; set; }
public Dictionary<string, object> Properties { get; set; } = new();
public bool IncludeDetailedData { get; set; }
}
}
public static class DictionaryExtensions
{
public static Dictionary<string, object> Merge(this Dictionary<string, object> first, Dictionary<string, object> second)
{
var result = new Dictionary<string, object>(first);
foreach (var kvp in second)
{
result[kvp.Key] = kvp.Value;
}
return result;
}
}
public sealed class CrashReportService
{
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
private bool _isInitialized;
private bool _isEnabled;
private readonly ISettingsFacadeService _settingsFacade;
private readonly DeviceIdService _deviceIdService;
private readonly PluginSdk.ISettingsService _settingsService;
public CrashReportService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_settingsService = settingsFacade.Settings;
_deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
_settingsService.Changed += OnSettingsChanged;
}
private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
{
if (e.Scope == PluginSdk.SettingsScope.App &&
e.ChangedKeys is not null &&
(e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
{
AppLogger.Info("CrashReport", "Settings changed, refreshing enabled state.");
RefreshEnabledState();
}
}
public void RefreshEnabledState()
{
try
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var newEnabled = snapshot.UploadAnonymousCrashData;
if (_isEnabled != newEnabled)
{
_isEnabled = newEnabled;
AppLogger.Info("CrashReport", $"Crash reporting enabled state changed to '{_isEnabled}'.");
if (_isEnabled && !_isInitialized)
{
InitializeSentry();
}
}
}
catch (Exception ex)
{
AppLogger.Warn("CrashReport", "Failed to refresh crash reporting enabled state.", ex);
_isEnabled = false;
}
}
private void InitializeSentry()
{
if (_isInitialized)
{
return;
}
_isInitialized = true;
try
{
SentrySdk.Init(options =>
{
options.Dsn = SentryDsn;
options.AutoSessionTracking = true;
options.AttachStacktrace = true;
options.MaxBreadcrumbs = 100;
options.Release = GetAppVersion();
options.Environment = GetEnvironment();
});
ConfigureCrashReportingScope();
AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
#if DEBUG
SentrySdk.CaptureMessage($"Crash reporting enabled - Debug mode test. DeviceId={_deviceIdService.DeviceId}");
#endif
}
catch (Exception ex)
{
AppLogger.Warn("CrashReport", "Failed to initialize Sentry crash reporting.", ex);
_isInitialized = false;
}
}
private void ConfigureCrashReportingScope()
{
try
{
SentrySdk.ConfigureScope(scope =>
{
scope.User = new SentryUser
{
Id = _deviceIdService.DeviceId
};
scope.SetTag("data_type", "crash_report");
scope.SetTag("device_id", _deviceIdService.DeviceId);
scope.SetTag("device_name", GetDeviceName());
scope.SetTag("device_model", GetDeviceModel());
scope.SetTag("device_arch", GetDeviceArchitecture());
scope.SetTag("os_name", GetOsName());
scope.SetTag("os_version", GetOsVersion());
scope.SetTag("language", GetSystemLanguage());
});
AppLogger.Info("CrashReport", $"Crash reporting scope configured. DeviceId={_deviceIdService.DeviceId}");
}
catch (Exception ex)
{
AppLogger.Warn("CrashReport", "Failed to configure crash reporting scope.", ex);
}
}
public bool IsEnabled => _isEnabled;
public string DeviceId => _deviceIdService.DeviceId;
public void SendShutdownEvent()
{
try
{
if (_isEnabled && _isInitialized)
{
AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
return;
}
if (!_isInitialized)
{
SentrySdk.Init(options =>
{
options.Dsn = SentryDsn;
options.AutoSessionTracking = false;
options.Release = GetAppVersion();
options.Environment = GetEnvironment();
});
}
SentrySdk.ConfigureScope(scope =>
{
scope.User = new SentryUser
{
Id = _deviceIdService.DeviceId
};
scope.SetTag("data_type", "shutdown");
scope.SetTag("device_id", _deviceIdService.DeviceId);
scope.SetTag("app_version", GetAppVersion());
});
SentrySdk.CaptureMessage($"app_shutdown - DeviceId={_deviceIdService.DeviceId}");
SentrySdk.Flush(TimeSpan.FromSeconds(3));
AppLogger.Info("CrashReport", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
}
catch (Exception ex)
{
AppLogger.Warn("CrashReport", "Failed to send shutdown event.", ex);
}
}
private static string GetDeviceName()
{
try { return Environment.MachineName ?? "Unknown"; }
catch { return "Unknown"; }
}
private static string GetDeviceModel()
{
var osDesc = RuntimeInformation.OSDescription;
if (osDesc.Contains("Windows")) return "Windows PC";
if (osDesc.Contains("Linux")) return "Linux PC";
if (osDesc.Contains("Darwin")) return "Mac";
return osDesc;
}
private static string GetDeviceArchitecture()
{
return RuntimeInformation.OSArchitecture.ToString();
}
private 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";
}
private static string GetOsVersion()
{
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
catch { return "Unknown"; }
}
private static string GetSystemLanguage()
{
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
catch { return "en-US"; }
}
private static string GetAppVersion()
{
var version = typeof(CrashReportService).Assembly.GetName().Version;
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
}
private static string GetEnvironment()
{
#if DEBUG
return "development";
#else
return "production";
#endif
}
}