From 46a8df5900c45e1b7fcfcd42edd26d9b293894cd Mon Sep 17 00:00:00 2001 From: lincube Date: Sat, 21 Mar 2026 16:16:02 +0800 Subject: [PATCH] 0.7.2 --- .../DesktopBootstrap.cs | 18 +- LanMountainDesktop/App.axaml | 1 + LanMountainDesktop/App.axaml.cs | 152 ++- .../Assets/Documents/Privacy.md | 340 +----- .../Models/AppSettingsSnapshot.cs | 6 +- LanMountainDesktop/Program.cs | 213 +--- .../Services/CrashReportService.cs | 1011 ----------------- .../Services/PostHogUsageTelemetryService.cs | 629 ++++++++++ .../Services/SentryCrashTelemetryService.cs | 410 +++++++ .../Settings/SettingsDomainServices.cs | 31 +- .../Services/TelemetryEnvironmentInfo.cs | 144 +++ LanMountainDesktop/Services/TelemetryEvent.cs | 55 + .../Services/TelemetryIdentityService.cs | 177 +++ .../Services/TelemetryServices.cs | 10 + .../PrivacySettingsPageViewModel.cs | 60 +- .../Views/MainWindow.ComponentSystem.cs | 26 + LanMountainDesktop/Views/MainWindow.axaml.cs | 11 + .../SettingsPages/PrivacySettingsPage.axaml | 12 +- .../Views/SettingsWindow.axaml.cs | 21 + .../plugins/PluginRuntimeService.cs | 2 +- 20 files changed, 1744 insertions(+), 1585 deletions(-) delete mode 100644 LanMountainDesktop/Services/CrashReportService.cs create mode 100644 LanMountainDesktop/Services/PostHogUsageTelemetryService.cs create mode 100644 LanMountainDesktop/Services/SentryCrashTelemetryService.cs create mode 100644 LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs create mode 100644 LanMountainDesktop/Services/TelemetryEvent.cs create mode 100644 LanMountainDesktop/Services/TelemetryIdentityService.cs create mode 100644 LanMountainDesktop/Services/TelemetryServices.cs diff --git a/LanMountainDesktop.DesktopHost/DesktopBootstrap.cs b/LanMountainDesktop.DesktopHost/DesktopBootstrap.cs index 6d2e2c3..d379527 100644 --- a/LanMountainDesktop.DesktopHost/DesktopBootstrap.cs +++ b/LanMountainDesktop.DesktopHost/DesktopBootstrap.cs @@ -5,16 +5,20 @@ namespace LanMountainDesktop.DesktopHost; public static class DesktopBootstrap { - public static void InitializeStartupServices(Action initializeDeviceId, Action initializeCrashReporting, Action initializeUserBehaviorAnalytics, Action scheduleStartupCleanup) + public static void InitializeStartupServices( + Action initializeTelemetryIdentity, + Action initializeCrashTelemetry, + Action initializeUsageTelemetry, + Action scheduleStartupCleanup) { - ArgumentNullException.ThrowIfNull(initializeDeviceId); - ArgumentNullException.ThrowIfNull(initializeCrashReporting); - ArgumentNullException.ThrowIfNull(initializeUserBehaviorAnalytics); + ArgumentNullException.ThrowIfNull(initializeTelemetryIdentity); + ArgumentNullException.ThrowIfNull(initializeCrashTelemetry); + ArgumentNullException.ThrowIfNull(initializeUsageTelemetry); ArgumentNullException.ThrowIfNull(scheduleStartupCleanup); - initializeDeviceId(); - initializeCrashReporting(); - initializeUserBehaviorAnalytics(); + initializeTelemetryIdentity(); + initializeCrashTelemetry(); + initializeUsageTelemetry(); scheduleStartupCleanup(); } diff --git a/LanMountainDesktop/App.axaml b/LanMountainDesktop/App.axaml index f4e0fa1..2f801bc 100644 --- a/LanMountainDesktop/App.axaml +++ b/LanMountainDesktop/App.axaml @@ -71,4 +71,5 @@ + diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 0a8076f..729d424 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -57,7 +57,12 @@ public partial class App : Application private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop; private ShutdownIntent _shutdownIntent; - private TrayIcons? _trayIcons; + private TrayIcon? _trayIcon; + private NativeMenuItem? _trayShowDesktopMenuItem; + private NativeMenuItem? _traySettingsMenuItem; + private NativeMenuItem? _trayComponentLibraryMenuItem; + private NativeMenuItem? _trayRestartMenuItem; + private NativeMenuItem? _trayExitMenuItem; private PluginRuntimeService? _pluginRuntimeService; private MainWindow? _mainWindow; private bool _mainWindowClosed; @@ -65,7 +70,6 @@ public partial class App : Application private DesktopShellHost? _desktopShellHost; internal static SingleInstanceService? CurrentSingleInstanceService { get; set; } - internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; } internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle => (Current as App)?._hostApplicationLifecycle; @@ -244,18 +248,43 @@ public partial class App : Application { try { - DisposeTrayIcon(); - - var trayIcon = new TrayIcon + if (_trayIcon is null) { - Icon = _appLogoService.CreateTrayIcon(), - ToolTipText = L("tray.tooltip", "LanMountainDesktop"), - Menu = BuildTrayMenu(), - IsVisible = true - }; + _trayShowDesktopMenuItem = new NativeMenuItem(); + _trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick; - _trayIcons = [trayIcon]; - TrayIcon.SetIcons(this, _trayIcons); + _traySettingsMenuItem = new NativeMenuItem(); + _traySettingsMenuItem.Click += OnTraySettingsClick; + + _trayComponentLibraryMenuItem = new NativeMenuItem(); + _trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick; + + _trayRestartMenuItem = new NativeMenuItem(); + _trayRestartMenuItem.Click += OnTrayRestartClick; + + _trayExitMenuItem = new NativeMenuItem(); + _trayExitMenuItem.Click += OnTrayExitClick; + + var trayMenu = new NativeMenu(); + trayMenu.Items.Add(_trayShowDesktopMenuItem); + trayMenu.Items.Add(_traySettingsMenuItem); + trayMenu.Items.Add(_trayComponentLibraryMenuItem); + trayMenu.Items.Add(new NativeMenuItemSeparator()); + trayMenu.Items.Add(_trayRestartMenuItem); + trayMenu.Items.Add(new NativeMenuItemSeparator()); + trayMenu.Items.Add(_trayExitMenuItem); + + _trayIcon = new TrayIcon + { + Icon = _appLogoService.CreateTrayIcon(), + Menu = trayMenu, + IsVisible = true + }; + + TrayIcon.SetIcons(this, [_trayIcon]); + } + + RefreshTrayIconContent(); } catch (Exception ex) { @@ -263,51 +292,58 @@ public partial class App : Application } } - private NativeMenu BuildTrayMenu() + private void RefreshTrayIconContent() { - var menu = new NativeMenu(); + if (_trayIcon is not null) + { + _trayIcon.IsVisible = true; + if (!OperatingSystem.IsLinux()) + { + _trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop"); + } + } - var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop")); - showDesktopItem.Click += OnTrayShowDesktopClick; - menu.Items.Add(showDesktopItem); + if (_trayShowDesktopMenuItem is not null) + { + _trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop"); + } - var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings")); - settingsItem.Click += OnTraySettingsClick; - menu.Items.Add(settingsItem); + if (_traySettingsMenuItem is not null) + { + _traySettingsMenuItem.Header = L("tray.menu.settings", "Settings"); + } - var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library")); - componentLibraryItem.Click += OnTrayComponentLibraryClick; - menu.Items.Add(componentLibraryItem); + if (_trayComponentLibraryMenuItem is not null) + { + _trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library"); + } - menu.Items.Add(new NativeMenuItemSeparator()); + if (_trayRestartMenuItem is not null) + { + _trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App"); + } - var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App")); - restartItem.Click += OnTrayRestartClick; - menu.Items.Add(restartItem); - - menu.Items.Add(new NativeMenuItemSeparator()); - - var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App")); - exitItem.Click += OnTrayExitClick; - menu.Items.Add(exitItem); - - return menu; + if (_trayExitMenuItem is not null) + { + _trayExitMenuItem.Header = L("tray.menu.exit", "Exit App"); + } } private void DisposeTrayIcon() { - if (_trayIcons is null) + if (_trayIcon is null) { return; } - TrayIcon.SetIcons(this, null); - foreach (var trayIcon in _trayIcons) + try { - trayIcon.Dispose(); + _trayIcon.IsVisible = false; + } + catch (Exception ex) + { + AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex); } - - _trayIcons = null; } private void EnsureSettingsWindowService() @@ -520,10 +556,7 @@ public partial class App : Application // 清除本地化缓存,强制重新加载语言文件 _localizationService.ClearCache(); ApplyCurrentCultureFromSettings(); - if (_trayIcons is not null) - { - InitializeTrayIcon(); - } + RefreshTrayIconContent(); } }, DispatcherPriority.Background); } @@ -591,13 +624,13 @@ public partial class App : Application try { - var (analytics, crashReport) = App.AnalyticsServices; - analytics?.SendShutdownEvent(); - crashReport?.SendShutdownEvent(); + TelemetryServices.Usage?.Shutdown( + _shutdownIntent == ShutdownIntent.RestartRequested, + "App.PerformExitCleanup"); } catch (Exception ex) { - AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex); + AppLogger.Warn("Analytics", "Failed to shut down usage telemetry during exit cleanup.", ex); } try @@ -631,6 +664,27 @@ public partial class App : Application AudioRecorderServiceFactory.DisposeSharedServices(); StudyAnalyticsServiceFactory.DisposeSharedService(); DisposeTrayIcon(); + + try + { + TelemetryServices.Crash?.CaptureShutdown( + _shutdownIntent == ShutdownIntent.RestartRequested, + "App.PerformExitCleanup"); + } + catch (Exception ex) + { + AppLogger.Warn("Analytics", "Failed to capture crash shutdown telemetry during exit cleanup.", ex); + } + + try + { + TelemetryServices.Crash?.Dispose(); + TelemetryServices.Usage?.Dispose(); + } + catch (Exception ex) + { + AppLogger.Warn("Analytics", "Failed to dispose telemetry services during exit cleanup.", ex); + } } private MainWindow CreateAndAssignMainWindow( diff --git a/LanMountainDesktop/Assets/Documents/Privacy.md b/LanMountainDesktop/Assets/Documents/Privacy.md index 8ebf5b8..a49e00b 100644 --- a/LanMountainDesktop/Assets/Documents/Privacy.md +++ b/LanMountainDesktop/Assets/Documents/Privacy.md @@ -1,326 +1,54 @@ -# LanMountainDesktop 隐私政策 +# 隐私与遥测说明 -**最后更新日期:2026年3月17日** +LanMountainDesktop 提供两类可选遥测能力: ---- +- 崩溃数据上传 +- 行为数据分析 -## 引言 +这两个开关默认关闭。即使两项都关闭,应用仍会在首次启动时向 PostHog 发送一次最小化的启动基线事件,用于统计用户量。 -欢迎使用 LanMountainDesktop!我们非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。 +## 默认行为 -**请在使用本应用前仔细阅读本隐私政策。使用本应用即表示您同意本政策的条款。** +当“崩溃数据上传”和“行为数据分析”都关闭时: ---- +- 仅首次启动会发送一次 `app_first_launch` 事件 +- 该事件只用于统计用户量 +- 事件时间由 PostHog 接入侧记录的请求时间和启动时间决定 +- 不会主动上传设备型号、操作系统细节、组件操作轨迹等详细信息 -## 1. 数据收集范围 +## 崩溃数据上传 -### 1.1 我们收集的数据 +当开启“崩溃数据上传”时,应用会把崩溃与未处理异常发送到 Sentry,用于分析稳定性问题。 -当您启用匿名数据收集功能时,我们会收集以下数据: +上报内容可能包括: -#### 匿名崩溃数据 -- **崩溃报告**:应用崩溃时的错误日志和堆栈跟踪 -- **设备信息**:操作系统版本、设备型号、架构(x64/x86) -- **应用版本**:当前使用的应用版本号 -- **设备标识符**:匿名生成的唯一设备ID(不包含个人信息) +- 异常堆栈和错误上下文 +- 应用版本与运行环境 +- 操作系统信息 +- 设备基础信息 +- 最近的日志尾部内容 -#### 匿名使用数据 -- **应用启动和关闭事件**:记录应用何时启动和关闭 -- **功能使用统计**:哪些功能被使用、使用频率 -- **设置变更**:用户更改了哪些设置(不包含具体设置值) -- **界面交互**:点击了哪些按钮、访问了哪些页面 -- **设备信息**:操作系统、应用版本、设备类型 +应用退出或崩溃时,会尽量补充最后一次会话和日志信息,方便定位问题。 -### 1.2 始终收集的基础数据 +## 行为数据分析 -**重要说明:** 为了统计应用的用户数量和日活跃用户,即使您关闭了匿名数据收集开关,我们仍会收集以下基础数据: +当开启“行为数据分析”时,应用会把关键行为事件发送到 PostHog,用于分析功能使用情况和会话路径。 -- ✅ **应用启动事件**:用于统计日活跃用户 -- ✅ **设备标识符**:用于区分不同用户(不包含个人信息) -- ✅ **应用版本**:用于统计版本分布 +上报内容可能包括: -**这些基础数据不包含任何个人身份信息,仅用于统计用户数量和应用使用情况。** +- 应用启动和退出时间 +- 会话开始与结束时间 +- 设置页打开、关闭和导航 +- 抽屉打开和关闭 +- 桌面组件的放置、移动、缩放、删除和编辑入口 -### 1.3 我们不收集的数据 +这些事件会被转换成 PostHog 可以直接接收和分析的事件格式,方便在 PostHog 中按事件流查看用户行为。桌面端的“回放”能力通过事件时间线重建,而不是浏览器式 Session Replay。 -我们**明确承诺不收集**以下数据: +## 身份与隐私控制 -- ❌ 个人身份信息(姓名、邮箱、电话等) -- ❌ 真实姓名或用户名 -- ❌ 地理位置信息(精确位置) -- ❌ 文件内容或文档数据 -- ❌ 密码或凭据信息 -- ❌ 网络浏览历史 -- ❌ 联系人信息 -- ❌ 照片、视频或音频文件 +应用会使用随机生成的匿名 install ID 和可刷新 telemetry ID 来区分安装与运行会话。 ---- +- 刷新 telemetry ID 只会影响后续详细遥测 +- 关闭开关后,不会继续发送对应类别的详细遥测 +- IP 只会通过 Sentry / PostHog 的服务端接入侧自然记录,不会作为自定义字段重复上报 -## 2. 数据收集目的 - -我们收集数据的目的如下: - -### 2.1 基础数据用途(始终收集) -- **统计用户数量**:了解应用的用户规模 -- **统计日活跃用户**:了解应用的活跃程度 -- **版本分布统计**:了解用户使用的版本情况 - -### 2.2 崩溃数据用途 -- **提高应用稳定性**:识别和修复崩溃问题 -- **优化性能**:分析性能瓶颈 -- **改进用户体验**:了解应用在不同设备上的表现 - -### 2.3 使用数据用途 -- **功能优化**:了解哪些功能最受欢迎,优先改进 -- **用户体验改进**:优化界面设计和交互流程 -- **统计分析**:了解用户规模和使用趋势 -- **产品决策**:基于数据做出产品发展方向决策 - ---- - -## 3. 数据存储和处理 - -### 3.1 数据存储位置 - -我们使用以下第三方服务存储和处理数据: - -#### Sentry(崩溃报告) -- **用途**:崩溃数据收集和分析 -- **位置**:美国 -- **官网**:https://sentry.io -- **隐私政策**:https://sentry.io/privacy/ - -#### PostHog(使用分析) -- **用途**:用户行为分析和统计 -- **位置**:美国 -- **官网**:https://posthog.com -- **隐私政策**:https://posthog.com/privacy - -### 3.2 数据保留期限 - -- **崩溃数据**:保留90天后自动删除 -- **使用数据**:保留12个月后自动删除 -- **设备标识符**:永久保留(用于统计日活用户) - -### 3.3 数据安全措施 - -我们采取以下安全措施保护您的数据: - -- ✅ 数据传输使用HTTPS加密 -- ✅ 数据存储使用加密技术 -- ✅ 访问权限严格控制 -- ✅ 定期安全审计 - ---- - -## 4. 数据共享 - -### 4.1 我们不会出售您的数据 - -我们**明确承诺**: -- ❌ 不会出售您的个人数据 -- ❌ 不会将您的数据用于广告目的 -- ❌ 不会与第三方共享可识别个人的数据 - -### 4.2 必要的共享 - -我们仅在以下情况下共享数据: - -- **服务提供商**:与Sentry和PostHog共享数据以提供服务 -- **法律要求**:在法律要求或政府机构合法要求时 - ---- - -## 5. 您的权利 - -### 5.1 选择权 - -您完全控制详细数据收集: - -- **匿名崩溃数据**:可在设置中开启或关闭 -- **匿名使用数据**:可在设置中开启或关闭 -- **基础数据**:始终收集(用于统计用户数量) - -**注意:** 即使关闭所有开关,我们仍会收集基础数据(应用启动事件和设备标识符)以统计用户数量。 - -### 5.2 数据访问权 - -您可以: -- 查看我们收集的数据类型 -- 了解数据的使用目的 -- 了解数据的存储位置 - -### 5.3 数据删除权 - -您可以: -- 随时关闭详细数据收集功能 -- 删除本地存储的设备标识符 -- 联系我们删除已收集的数据 - ---- - -## 6. 设备标识符 - -### 6.1 什么是设备标识符? - -设备标识符是一个随机生成的唯一字符串,用于: -- 统计日活用户数量 -- 区分不同设备 -- 分析用户使用趋势 - -### 6.2 设备标识符的特点 - -- **匿名性**:不包含任何个人信息 -- **随机性**:通过SHA256算法生成 -- **唯一性**:每个设备有唯一标识符 -- **持久性**:即使刷新设备ID,仍能关联到同一用户 -- **可重置**:您可以在设置中刷新设备标识符 - -### 6.3 设备标识符刷新 - -当您刷新设备标识符时: -- 生成新的设备ID -- **持久用户ID保持不变**,确保仍能关联到同一用户 -- 统计数据不会丢失 - -### 6.4 设备标识符示例 - -``` -a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 -``` - ---- - -## 7. 儿童隐私保护 - -本应用不面向13岁以下儿童。我们不会故意收集儿童的个人信息。如果您发现我们无意中收集了儿童的数据,请联系我们,我们将立即删除相关数据。 - ---- - -## 8. 国际数据传输 - -由于我们的服务提供商位于美国,您的数据可能会被传输到美国。我们确保: - -- 数据传输符合相关法律法规 -- 服务提供商遵守GDPR等隐私法规 -- 采取适当的安全措施保护数据 - ---- - -## 9. 隐私政策更新 - -我们可能会不时更新本隐私政策。更新时,我们将: - -- 在本页面更新"最后更新日期" -- 在应用内通知您重大变更 -- 继续使用应用即表示您同意更新后的政策 - ---- - -## 10. 联系我们 - -如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们: - -- **GitHub Issues**:https://github.com/wwiinnddyy/LanMountainDesktop/issues -- **电子邮件**:[您的邮箱地址] - ---- - -## 11. 法律依据 - -### 11.1 GDPR合规 - -如果您位于欧洲经济区(EEA),我们的数据处理基于: - -- **同意**:您明确同意数据收集 -- **合法利益**:改进应用性能和用户体验 - -### 11.2 CCPA合规 - -如果您是加州居民,您有权: - -- 知道我们收集了哪些数据 -- 要求删除您的数据 -- 选择退出数据销售(我们不销售数据) - ---- - -## 12. 第三方链接 - -本应用可能包含第三方网站链接。我们不对这些网站的隐私政策负责。请阅读这些网站的隐私政策。 - ---- - -## 13. 数据收集示例 - -### 13.1 崩溃报告示例 - -```json -{ - "event_id": "abc123", - "timestamp": "2024-01-01T12:00:00Z", - "device_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", - "app_version": "1.0.0", - "os_name": "Windows", - "os_version": "10.0.19041", - "error_message": "NullReferenceException", - "stack_trace": "..." -} -``` - -### 13.2 使用数据示例 - -```json -{ - "event": "app_online", - "timestamp": "2024-01-01T12:00:00Z", - "distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", - "properties": { - "app_version": "1.0.0", - "os_name": "Windows", - "event_type": "app_start", - "analytics_enabled": true - } -} -``` - -### 13.3 基础数据示例(始终收集) - -```json -{ - "event": "$pageview", - "timestamp": "2024-01-01T12:00:00Z", - "distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", - "properties": { - "$current_url": "app://main", - "$title": "LanMountainDesktop" - } -} -``` - ---- - -## 14. 您的同意 - -使用本应用即表示您: - -- ✅ 已阅读并理解本隐私政策 -- ✅ 同意我们按照本政策收集和使用数据 -- ✅ 了解您可以随时撤回同意(详细数据收集) -- ✅ 了解基础数据将始终收集以统计用户数量 - ---- - -## 15. 免责声明 - -本隐私政策仅适用于 LanMountainDesktop 应用。我们不对以下情况负责: - -- 第三方服务的隐私政策 -- 您自行分享的数据 -- 不可抗力导致的数据泄露 - ---- - -**感谢您信任阑山桌面LanMountainDesktop!** - -我们承诺保护您的隐私,并持续改进我们的隐私保护措施。 diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index e691e59..315970e 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -71,9 +71,11 @@ public sealed class AppSettingsSnapshot public bool UploadAnonymousUsageData { get; set; } - public string? DeviceId { get; set; } + public string? TelemetryInstallId { get; set; } - public string? PersistentUserId { get; set; } + public string? TelemetryId { get; set; } + + public bool HasReportedTelemetryBaseline { get; set; } public string UpdateChannel { get; set; } = "stable"; diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index a6d31d9..4cdb8e1 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -8,7 +8,6 @@ using LanMountainDesktop.DesktopHost; using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; -using Sentry; namespace LanMountainDesktop; @@ -21,11 +20,6 @@ sealed class Program { AppLogger.Initialize(); RegisterGlobalExceptionLogging(); - DesktopBootstrap.InitializeStartupServices( - InitializeDeviceId, - InitializeCrashReporting, - InitializeUserBehaviorAnalytics, - ScheduleWhiteboardNoteStartupCleanup); var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args); using var singleInstance = AcquireSingleInstance(restartParentProcessId); @@ -44,6 +38,12 @@ sealed class Program return; } + DesktopBootstrap.InitializeStartupServices( + InitializeTelemetryIdentity, + InitializeCrashTelemetry, + InitializeUsageTelemetry, + ScheduleWhiteboardNoteStartupCleanup); + var diagnostics = StartupDiagnosticsService.Run(args); StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics); @@ -53,7 +53,6 @@ sealed class Program StartupRenderMode = renderMode; AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'."); App.CurrentSingleInstanceService = singleInstance; - App.AnalyticsServices = (_userBehaviorAnalyticsService, _crashReportService); BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args); AppLogger.Info("Startup", "Application exited normally."); } @@ -185,204 +184,90 @@ sealed class Program { AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => { + var exception = eventArgs.ExceptionObject as Exception + ?? new Exception(eventArgs.ExceptionObject?.ToString() ?? "Unhandled exception."); + AppLogger.Critical( "UnhandledException", $"Unhandled exception. IsTerminating={eventArgs.IsTerminating}", - eventArgs.ExceptionObject as Exception); + exception); - if (eventArgs.IsTerminating) + try { - SentrySdk.Flush(TimeSpan.FromSeconds(5)); + TelemetryServices.Crash?.CaptureUnhandledException( + exception, + "AppDomain.UnhandledException", + eventArgs.IsTerminating); + } + catch (Exception telemetryException) + { + AppLogger.Warn("UnhandledException", "Failed to forward unhandled exception to crash telemetry.", telemetryException); } }; TaskScheduler.UnobservedTaskException += (_, eventArgs) => { AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception); + + try + { + TelemetryServices.Crash?.CaptureTaskException( + eventArgs.Exception, + "TaskScheduler.UnobservedTaskException"); + } + catch (Exception telemetryException) + { + AppLogger.Warn("TaskScheduler", "Failed to forward task exception to crash telemetry.", telemetryException); + } + eventArgs.SetObserved(); }; } - private static void InitializeDeviceId() + private static void InitializeTelemetryIdentity() { try { - DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate()); - AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}"); + TelemetryIdentityService.Initialize(HostSettingsFacadeProvider.GetOrCreate()); + AppLogger.Info( + "Startup", + $"Telemetry identity initialized. InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}."); } catch (Exception ex) { - AppLogger.Warn("Startup", "Failed to initialize DeviceIdService.", ex); + AppLogger.Warn("Startup", "Failed to initialize telemetry identity service.", ex); } } - private static void InitializeSentryForAnalytics() - { - try - { - var deviceId = DeviceIdService.Instance.DeviceId; - - SentrySdk.Init(options => - { - options.Dsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504"; - options.AutoSessionTracking = true; - options.Release = GetAppVersion(); - options.Environment = GetEnvironment(); - }); - - SentrySdk.ConfigureScope(scope => - { - scope.User = new SentryUser - { - Id = deviceId - }; - - scope.SetTag("data_type", "analytics"); - scope.SetTag("device_id", deviceId); - scope.SetTag("app_version", GetAppVersion()); - scope.SetTag("os_name", GetOsName()); - scope.SetTag("os_version", GetOsVersion()); - scope.SetTag("os_build", GetOsBuild()); - scope.SetTag("device_model", GetDeviceModel()); - scope.SetTag("device_arch", GetDeviceArchitecture()); - scope.SetTag("processor_count", GetProcessorCount().ToString()); - scope.SetTag("total_memory_mb", GetTotalMemoryMB().ToString()); - scope.SetTag("runtime_version", GetRuntimeVersion()); - scope.SetTag("language", GetSystemLanguage()); - scope.SetTag("clr_version", GetClrVersion()); - scope.SetTag("is_64bit", Environment.Is64BitOperatingSystem.ToString()); - }); - - SentrySdk.CaptureMessage("user_active"); - - AppLogger.Info("Startup", $"Analytics service initialized. DeviceId={deviceId}"); - } - catch (Exception ex) - { - AppLogger.Warn("Startup", "Failed to initialize analytics service.", ex); - } - } - - private static string GetAppVersion() - { - var version = typeof(Program).Assembly.GetName().Version; - return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}"; - } - - private static string GetOsName() - { - if (OperatingSystem.IsWindows()) return "Windows"; - if (OperatingSystem.IsLinux()) return "Linux"; - if (OperatingSystem.IsMacOS()) return "macOS"; - return "Unknown"; - } - - private static string GetOsVersion() - { - try { return Environment.OSVersion.VersionString ?? "Unknown"; } - catch { return "Unknown"; } - } - - private static string GetOsBuild() - { - try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; } - catch { return "Unknown"; } - } - - private static string GetDeviceName() - { - try { return Environment.MachineName ?? "Unknown"; } - catch { return "Unknown"; } - } - - private static string GetDeviceModel() - { - if (OperatingSystem.IsWindows()) return "Windows PC"; - if (OperatingSystem.IsLinux()) return "Linux PC"; - if (OperatingSystem.IsMacOS()) return "Mac"; - return "Unknown"; - } - - private static string GetDeviceArchitecture() - { - return Environment.Is64BitOperatingSystem ? "x64" : "x86"; - } - - 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 GetSystemLanguage() - { - try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; } - catch { return "en-US"; } - } - - private static string GetClrVersion() - { - return Environment.Version.ToString(); - } - - private static CrashReportService? _crashReportService; - private static UserBehaviorAnalyticsService? _userBehaviorAnalyticsService; - - private static void InitializeCrashReporting() + private static void InitializeCrashTelemetry() { try { var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); - _crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance); - _crashReportService.RefreshEnabledState(); + var crashTelemetry = new SentryCrashTelemetryService(settingsFacade); + TelemetryServices.Crash = crashTelemetry; + crashTelemetry.Initialize(); + AppLogger.Info("Startup", $"Crash telemetry initialized. Enabled={crashTelemetry.IsEnabled}."); } catch (Exception ex) { - AppLogger.Warn("Startup", "Failed to initialize crash reporting service.", ex); + AppLogger.Warn("Startup", "Failed to initialize crash telemetry service.", ex); } } - private static void InitializeUserBehaviorAnalytics() + private static void InitializeUsageTelemetry() { try { var settingsFacade = HostSettingsFacadeProvider.GetOrCreate(); - _userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance); - _userBehaviorAnalyticsService.Initialize(); + var usageTelemetry = new PostHogUsageTelemetryService(settingsFacade); + TelemetryServices.Usage = usageTelemetry; + usageTelemetry.Initialize(); + AppLogger.Info("Startup", $"Usage telemetry initialized. Enabled={usageTelemetry.IsUsageEnabled}."); } catch (Exception ex) { - AppLogger.Warn("Startup", "Failed to initialize user behavior analytics service.", ex); + AppLogger.Warn("Startup", "Failed to initialize usage telemetry service.", ex); } } - - private static string GetReleaseVersion() - { - var assembly = typeof(Program).Assembly; - var version = assembly.GetName().Version; - if (version is null) - { - return "1.0.0"; - } - return version.Major >= 0 ? $"{version.Major}.{version.Minor}.{version.Build}" : "1.0.0"; - } - - private static string GetEnvironment() - { -#if DEBUG - return "development"; -#else - return "production"; -#endif - } } diff --git a/LanMountainDesktop/Services/CrashReportService.cs b/LanMountainDesktop/Services/CrashReportService.cs deleted file mode 100644 index b26ed15..0000000 --- a/LanMountainDesktop/Services/CrashReportService.cs +++ /dev/null @@ -1,1011 +0,0 @@ -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 string? _persistentUserId; // 持久化的用户ID,用于关联设备 - 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; - } - } - - // 持久化的用户ID,用于跨设备关联用户 - public string PersistentUserId - { - get - { - if (_persistentUserId is null) - { - throw new InvalidOperationException("PersistentUserId not initialized"); - } - return _persistentUserId; - } - } - - private void EnsureDeviceId() - { - if (_isInitialized) - { - return; - } - - _isInitialized = true; - - try - { - var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); - - // 初始化或生成持久化用户ID(只生成一次,永不改变) - if (string.IsNullOrEmpty(snapshot.PersistentUserId)) - { - snapshot.PersistentUserId = GeneratePersistentUserId(); - AppLogger.Info("DeviceId", $"Generated new persistent user ID: {snapshot.PersistentUserId}"); - } - _persistentUserId = snapshot.PersistentUserId; - - // 初始化或生成设备ID(可以刷新) - if (string.IsNullOrEmpty(snapshot.DeviceId)) - { - snapshot.DeviceId = GenerateDeviceId(); - _settingsFacade.Settings.SaveSnapshot( - SettingsScope.App, - snapshot, - changedKeys: [nameof(AppSettingsSnapshot.DeviceId), nameof(AppSettingsSnapshot.PersistentUserId)]); - _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(); - _persistentUserId = GeneratePersistentUserId(); - 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(); - } - - private static string GeneratePersistentUserId() - { - // 生成一个永久性的用户ID,基于机器名和用户名的哈希 - var userInfo = $"{Environment.MachineName}|{Environment.UserName}|LanMountainDesktop"; - using var sha = System.Security.Cryptography.SHA256.Create(); - var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(userInfo)); - 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 _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)); - - // 发送PostHog标准的$pageview事件用于统计日活(始终发送,不受开关影响) - CaptureEvent("$pageview", new Dictionary - { - { "$current_url", "app://main" }, - { "$title", "LanMountainDesktop" } - }); - - // 发送应用启动事件(始终发送,用于统计用户数量) - CaptureEvent("app_online", new Dictionary - { - { "event_type", "app_start" }, - { "analytics_enabled", _isEnabled } - }); - - 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 - { - { "component", componentName }, - { "action", action ?? "click" } - }); - } - - public void TrackComponentDrag(string componentId, string action) - { - if (!_isEnabled || !_isInitialized) - { - return; - } - - CaptureEvent("component_drag", new Dictionary - { - { "component_id", componentId }, - { "action", action } - }); - } - - public void TrackComponentDrop(string componentId, string targetPosition) - { - if (!_isEnabled || !_isInitialized) - { - return; - } - - CaptureEvent("component_drop", new Dictionary - { - { "component_id", componentId }, - { "target_position", targetPosition } - }); - } - - public void TrackSettingsOpen(string settingsPage) - { - if (!_isEnabled || !_isInitialized) - { - return; - } - - CaptureEvent("settings_open", new Dictionary - { - { "page", settingsPage } - }); - } - - public void TrackSettingsChange(string settingsPage, string settingKey, string? oldValue, string newValue) - { - if (!_isEnabled || !_isInitialized) - { - return; - } - - CaptureEvent("settings_change", new Dictionary - { - { "page", settingsPage }, - { "key", settingKey }, - { "old_value", oldValue ?? "" }, - { "new_value", newValue } - }); - } - - public void TrackSettingsClose(string settingsPage) - { - if (!_isEnabled || !_isInitialized) - { - return; - } - - CaptureEvent("settings_close", new Dictionary - { - { "page", settingsPage } - }); - } - - public void TrackUpdateAction(string action, string? version = null) - { - if (!_isEnabled || !_isInitialized) - { - return; - } - - var props = new Dictionary - { - { "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 - { - { "action", action } - }); - } - - public void TrackNavigation(string fromPage, string toPage) - { - if (!_isEnabled || !_isInitialized) - { - return; - } - - CaptureEvent("navigation", new Dictionary - { - { "from", fromPage }, - { "to", toPage } - }); - } - - public void SendCrashEvent() - { - if (!_isInitialized) - { - return; - } - - try - { - var properties = new Dictionary - { - { "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 - { - { "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(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()); - } - } - } - catch (Exception ex) - { - AppLogger.Warn("UserBehaviorAnalytics", "Failed to refresh analytics enabled state.", ex); - _isEnabled = false; - } - } - - public void CaptureEvent(string eventName, Dictionary? properties = null) - { - if (!_isInitialized) - { - return; - } - - try - { - // 基础事件($pageview, app_online, app_shutdown等)始终发送,用于统计用户数量 - bool isBasicEvent = eventName.StartsWith("$") || - eventName == "app_online" || - eventName == "app_shutdown" || - eventName == "$identify"; - - // 非基础事件只有在启用时才发送 - if (!isBasicEvent && !_isEnabled) - { - return; - } - - var eventData = new UserBehaviorEvent - { - Event = eventName, - DistinctId = _deviceIdService.PersistentUserId, // 使用持久化用户ID - Timestamp = DateTimeOffset.UtcNow, - Properties = properties ?? new Dictionary(), - 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 - { - { "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 - { - { "feature_name", featureName }, - { "action", action } - }); - } - - private void FlushEvents() - { - List eventsToSend; - - lock (_queueLock) - { - if (_eventQueue.Count == 0) - { - return; - } - - eventsToSend = new List(); - 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 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 - { - { "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 - { - { "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 - { - { "$app_version", GetAppVersion() }, - { "$os", GetOsName() }, - { "$os_version", GetOsVersion() }, - { "$device_type", GetDeviceModel() }, - { "$device_id", _deviceIdService.DeviceId } // 当前设备ID - }; - - // PostHog正确的$identify格式 - // 使用PersistentUserId作为distinct_id,确保设备ID刷新后仍能关联到同一用户 - var requestBody = new Dictionary - { - { "api_key", PostHogApiKey }, - { "event", "$identify" }, - { "distinct_id", _deviceIdService.PersistentUserId }, // 使用持久化用户ID - { "timestamp", DateTimeOffset.UtcNow.ToString("o") }, - { "properties", new Dictionary - { - { "$set", userProperties }, - { "$set_once", new Dictionary - { - { "first_app_open", DateTimeOffset.UtcNow.ToString("o") } - } - } - } - } - }; - - 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 GetEventProperties(UserBehaviorEvent e) - { - var props = new Dictionary - { - { "$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 Properties { get; set; } = new(); - public bool IncludeDetailedData { get; set; } - } -} - -public static class DictionaryExtensions -{ - public static Dictionary Merge(this Dictionary first, Dictionary second) - { - var result = new Dictionary(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(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(); - - // 显式开始会话跟踪 - SentrySdk.StartSession(); - - 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) - { - // 结束Sentry会话 - SentrySdk.EndSession(); - SentrySdk.Flush(TimeSpan.FromSeconds(3)); - AppLogger.Info("CrashReport", $"Shutdown event 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 - } -} diff --git a/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs b/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs new file mode 100644 index 0000000..d24d70b --- /dev/null +++ b/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs @@ -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 _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(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 + { + ["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 + { + ["source"] = source, + ["was_visible"] = wasVisible, + ["window_state"] = windowState + }, + forceFlush: true); + } + + public void TrackSettingsWindowOpened(string source, string? currentPageId) + { + CaptureEvent( + "settings_window_opened", + new Dictionary + { + ["source"] = source, + ["current_page_id"] = currentPageId + }, + forceFlush: true); + } + + public void TrackSettingsWindowClosed(string source, string? currentPageId) + { + CaptureEvent( + "settings_window_closed", + new Dictionary + { + ["source"] = source, + ["current_page_id"] = currentPageId + }, + forceFlush: true); + } + + public void TrackSettingsNavigation(string? fromPageId, string? toPageId, string source) + { + CaptureEvent( + "settings_navigation", + new Dictionary + { + ["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 + { + ["page_id"] = pageId, + ["drawer_title"] = drawerTitle + }, + forceFlush: true); + } + + public void TrackSettingsDrawerClosed(string? pageId, string? drawerTitle) + { + CaptureEvent( + "settings_drawer_closed", + new Dictionary + { + ["page_id"] = pageId, + ["drawer_title"] = drawerTitle + }, + forceFlush: true); + } + + public void TrackDesktopComponentPlaced(DesktopComponentPlacementSnapshot placement, string source) + { + CaptureEvent( + "desktop_component_placed", + new Dictionary + { + ["source"] = source + }, + stateAfter: DescribePlacement(placement), + forceFlush: true); + } + + public void TrackDesktopComponentMoved( + DesktopComponentPlacementSnapshot before, + DesktopComponentPlacementSnapshot after, + string source) + { + CaptureEvent( + "desktop_component_moved", + new Dictionary + { + ["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 + { + ["source"] = source + }, + stateBefore: DescribePlacement(before), + stateAfter: DescribePlacement(after), + forceFlush: true); + } + + public void TrackDesktopComponentDeleted(DesktopComponentPlacementSnapshot before, string source) + { + CaptureEvent( + "desktop_component_deleted", + new Dictionary + { + ["source"] = source + }, + stateBefore: DescribePlacement(before), + forceFlush: true); + } + + public void TrackDesktopComponentEditorOpened(DesktopComponentPlacementSnapshot placement, string source) + { + CaptureEvent( + "desktop_component_editor_opened", + new Dictionary + { + ["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 + { + ["api_key"] = PostHogApiKey, + ["event"] = "app_first_launch", + ["distinct_id"] = installId, + ["timestamp"] = timestamp.ToString("o"), + ["properties"] = new Dictionary + { + ["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 + { + ["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 + { + ["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? payload = null, + IReadOnlyDictionary? stateBefore = null, + IReadOnlyDictionary? 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(), + 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 eventsToSend; + + lock (_queueLock) + { + if (_eventQueue.Count == 0) + { + return; + } + + eventsToSend = new List(); + 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 + { + ["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 CreatePageState(string? pageId) + { + return new Dictionary + { + ["page_id"] = pageId + }; + } + + private static IReadOnlyDictionary DescribePlacement(DesktopComponentPlacementSnapshot placement) + { + return new Dictionary + { + ["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 + }; + } +} diff --git a/LanMountainDesktop/Services/SentryCrashTelemetryService.cs b/LanMountainDesktop/Services/SentryCrashTelemetryService.cs new file mode 100644 index 0000000..9aac45e --- /dev/null +++ b/LanMountainDesktop/Services/SentryCrashTelemetryService.cs @@ -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(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(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)); + } + } +} diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index 0105f7c..1f8142d 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -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(); + + 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); } } diff --git a/LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs b/LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs new file mode 100644 index 0000000..05c8506 --- /dev/null +++ b/LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs @@ -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" + }; + } +} diff --git a/LanMountainDesktop/Services/TelemetryEvent.cs b/LanMountainDesktop/Services/TelemetryEvent.cs new file mode 100644 index 0000000..cad7fa5 --- /dev/null +++ b/LanMountainDesktop/Services/TelemetryEvent.cs @@ -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 Payload, + IReadOnlyDictionary? StateBefore = null, + IReadOnlyDictionary? StateAfter = null) +{ + public Dictionary ToPostHogProperties() + { + var properties = new Dictionary(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 Copy(IReadOnlyDictionary source) + { + return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/LanMountainDesktop/Services/TelemetryIdentityService.cs b/LanMountainDesktop/Services/TelemetryIdentityService.cs new file mode 100644 index 0000000..0c4ec5f --- /dev/null +++ b/LanMountainDesktop/Services/TelemetryIdentityService.cs @@ -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(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(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(SettingsScope.App); + var changedKeys = new List(); + + 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"); + } +} diff --git a/LanMountainDesktop/Services/TelemetryServices.cs b/LanMountainDesktop/Services/TelemetryServices.cs new file mode 100644 index 0000000..4bb86e2 --- /dev/null +++ b/LanMountainDesktop/Services/TelemetryServices.cs @@ -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; } +} diff --git a/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs index 039d501..a22348b 100644 --- a/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs +++ b/LanMountainDesktop/ViewModels/PrivacySettingsPageViewModel.cs @@ -2,9 +2,9 @@ using System; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; -using LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.ViewModels; @@ -19,7 +19,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade) { - _settingsFacade = settingsFacade; + _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); RefreshLocalizedText(); @@ -35,7 +35,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase private bool _uploadAnonymousUsageData; [ObservableProperty] - private string _deviceId = string.Empty; + private string _telemetryId = string.Empty; [ObservableProperty] private string _privacyHeader = string.Empty; @@ -53,13 +53,13 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase private string _usageUploadDescription = string.Empty; [ObservableProperty] - private string _deviceIdHeader = string.Empty; + private string _telemetryIdHeader = string.Empty; [ObservableProperty] - private string _deviceIdDescription = string.Empty; + private string _telemetryIdDescription = string.Empty; [ObservableProperty] - private string _refreshDeviceIdText = string.Empty; + private string _refreshTelemetryIdText = string.Empty; [ObservableProperty] private string _viewPrivacyPolicyText = string.Empty; @@ -72,32 +72,27 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase var state = _settingsFacade.Privacy.Get(); UploadAnonymousCrashData = state.UploadAnonymousCrashData; UploadAnonymousUsageData = state.UploadAnonymousUsageData; - DeviceId = DeviceIdService.Instance.DeviceId; + TelemetryId = TelemetryServices.Identity?.TelemetryId ?? string.Empty; } [RelayCommand] - private void RefreshDeviceId() + private void RefreshTelemetryId() { try { - var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; - using var sha = System.Security.Cryptography.SHA256.Create(); - var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo)); - var newDeviceId = Convert.ToHexString(hash)[..32].ToLower(); + var identity = TelemetryServices.Identity; + if (identity is null) + { + AppLogger.Warn("PrivacySettings", "Telemetry identity service is unavailable."); + return; + } - var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); - snapshot.DeviceId = newDeviceId; - _settingsFacade.Settings.SaveSnapshot( - SettingsScope.App, - snapshot, - changedKeys: [nameof(Models.AppSettingsSnapshot.DeviceId)]); - - DeviceId = newDeviceId; - AppLogger.Info("PrivacySettings", $"Device ID refreshed: {newDeviceId}"); + TelemetryId = identity.RefreshTelemetryId(); + AppLogger.Info("PrivacySettings", $"Telemetry ID refreshed: {TelemetryId}"); } catch (Exception ex) { - AppLogger.Warn("PrivacySettings", "Failed to refresh device ID.", ex); + AppLogger.Warn("PrivacySettings", "Failed to refresh telemetry ID.", ex); } } @@ -132,12 +127,18 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase { PrivacyHeader = L("settings.privacy.title", "Privacy"); CrashUploadHeader = L("settings.privacy.crash_upload_title", "Anonymous crash data uploads"); - CrashUploadDescription = L("settings.privacy.crash_upload_description", "Help us improve application stability."); - UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage data uploads"); - UsageUploadDescription = L("settings.privacy.usage_upload_description", "Help us improve application features."); - DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID"); - DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate."); - RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh"); + CrashUploadDescription = L( + "settings.privacy.crash_upload_description", + "Send crash reports to help us improve stability."); + UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage analytics"); + UsageUploadDescription = L( + "settings.privacy.usage_upload_description", + "Send usage events to help us understand feature usage and session flow."); + TelemetryIdHeader = L("settings.privacy.telemetry_id_title", "Telemetry ID"); + TelemetryIdDescription = L( + "settings.privacy.telemetry_id_description", + "A refreshable anonymous identifier used for detailed telemetry sessions."); + RefreshTelemetryIdText = L("settings.privacy.refresh_telemetry_id", "Refresh"); PrivacyPolicyHintPrefix = L("settings.privacy.policy_hint_prefix", "For more details, please "); ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy"); } @@ -147,10 +148,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase { try { - // 触发隐私政策查看事件 AppLogger.Info("PrivacySettings", "User requested to view privacy policy."); - - // 发送事件通知显示隐私政策 ViewPrivacyPolicyRequested?.Invoke(); } catch (Exception ex) diff --git a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs index 6847ab3..d81b238 100644 --- a/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs +++ b/LanMountainDesktop/Views/MainWindow.ComponentSystem.cs @@ -634,6 +634,20 @@ public partial class MainWindow } } + private static DesktopComponentPlacementSnapshot ClonePlacementSnapshot(DesktopComponentPlacementSnapshot placement) + { + return new DesktopComponentPlacementSnapshot + { + PlacementId = placement.PlacementId, + PageIndex = placement.PageIndex, + ComponentId = placement.ComponentId, + Row = placement.Row, + Column = placement.Column, + WidthCells = placement.WidthCells, + HeightCells = placement.HeightCells + }; + } + private void OnSettingsWindowStateChanged(object? sender, EventArgs e) { _ = sender; @@ -875,6 +889,8 @@ public partial class MainWindow return; } + var before = ClonePlacementSnapshot(placement); + if (string.Equals(_componentEditorWindowService.CurrentPlacementId, placement.PlacementId, StringComparison.OrdinalIgnoreCase)) { _componentEditorWindowService.Close(); @@ -896,6 +912,7 @@ public partial class MainWindow ApplyTaskbarActionVisibility(GetCurrentTaskbarContext()); PersistSettings(); + TelemetryServices.Usage?.TrackDesktopComponentDeleted(before, "component.delete"); } private void OpenSelectedComponentEditor() @@ -912,6 +929,8 @@ public partial class MainWindow ComponentId: placement.ComponentId, PlacementId: placement.PlacementId, RefreshAction: () => RefreshDesktopComponentPlacement(placement.PlacementId))); + + TelemetryServices.Usage?.TrackDesktopComponentEditorOpened(ClonePlacementSnapshot(placement), "component.edit"); } private bool TryGetSelectedDesktopPlacement(out DesktopComponentPlacementSnapshot placement) @@ -1220,6 +1239,7 @@ public partial class MainWindow InvalidateDesktopPageAwareComponentContextCache(); UpdateDesktopPageAwareComponentContext(); PersistSettings(); + TelemetryServices.Usage?.TrackDesktopComponentPlaced(ClonePlacementSnapshot(placement), "component.create"); ApplyTaskbarActionVisibility(GetCurrentTaskbarContext()); } @@ -2350,6 +2370,8 @@ public partial class MainWindow return false; } + var before = ClonePlacementSnapshot(placement); + var widthCells = Math.Max(1, _desktopComponentResize.CurrentWidthCells); var heightCells = Math.Max(1, _desktopComponentResize.CurrentHeightCells); var changed = placement.WidthCells != widthCells || placement.HeightCells != heightCells; @@ -2360,6 +2382,7 @@ public partial class MainWindow if (changed) { PersistSettings(); + TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize"); } return true; @@ -2569,6 +2592,8 @@ public partial class MainWindow return false; } + var before = ClonePlacementSnapshot(placement); + placement.Row = Math.Max(0, row); placement.Column = Math.Max(0, column); @@ -2578,6 +2603,7 @@ public partial class MainWindow _desktopComponentDrag.SourceHost.Opacity = 1; ApplyDesktopEditStateToHost(_desktopComponentDrag.SourceHost, _isComponentLibraryOpen); PersistSettings(); + TelemetryServices.Usage?.TrackDesktopComponentMoved(before, ClonePlacementSnapshot(placement), "component.move"); return true; } diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index 5fd1af3..e176156 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -289,6 +289,10 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider ApplyNightModeState(_isNightMode, refreshPalettes: true); ApplyLocalization(); + TelemetryServices.Usage?.TrackMainWindowOpened( + "MainWindow.OnOpened", + IsVisible, + WindowState.ToString()); DesktopHost.SizeChanged += OnDesktopHostSizeChanged; RebuildDesktopGrid(); LoadLauncherEntriesAsync(); @@ -303,6 +307,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider protected override void OnClosed(EventArgs e) { + var wasVisible = IsVisible; + var windowState = WindowState.ToString(); + PersistSettings(); _componentEditorWindowService.Close(); if (_detachedComponentLibraryWindow is not null) @@ -329,6 +336,10 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider { settingsWindowService.StateChanged -= OnSettingsWindowStateChanged; } + TelemetryServices.Usage?.TrackMainWindowClosed( + "MainWindow.OnClosed", + wasVisible, + windowState); base.OnClosed(e); } diff --git a/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml index 42cc330..2b6218a 100644 --- a/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/PrivacySettingsPage.axaml @@ -38,15 +38,15 @@ Margin="0,16,0,0"> - - -