From 273791174f582302195bf3fda15c0e8973aa168b Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 13 Jul 2026 22:04:54 +0100 Subject: [PATCH] Add opt-in Timberlogs diagnostics --- .github/workflows/release.yml | 8 + src/SwitchifyPc.App/App.xaml.cs | 109 ++++++++- .../DiagnosticsConsentWindow.xaml | 29 +++ .../DiagnosticsConsentWindow.xaml.cs | 26 ++ src/SwitchifyPc.App/SettingsWindow.xaml | 30 +++ src/SwitchifyPc.App/SettingsWindow.xaml.cs | 22 ++ src/SwitchifyPc.App/SetupGuideWindow.xaml | 24 ++ src/SwitchifyPc.App/SetupGuideWindow.xaml.cs | 17 +- src/SwitchifyPc.App/SwitchifyPc.App.csproj | 6 +- .../Diagnostics/TelemetryReporter.cs | 230 ++++++++++++++++++ .../Diagnostics/TelemetrySanitizer.cs | 36 +++ .../Diagnostics/TelemetrySettings.cs | 72 ++++++ src/SwitchifyPc.Core/Ui/SettingsController.cs | 18 +- src/SwitchifyPc.Core/Ui/SettingsViewModel.cs | 10 + .../Ui/SetupGuideViewModel.cs | 26 +- .../SetupGuideViewModelTests.cs | 10 +- .../SetupGuideWindowTests.cs | 4 +- src/SwitchifyPc.Tests/TelemetryTests.cs | 144 +++++++++++ 18 files changed, 808 insertions(+), 13 deletions(-) create mode 100644 src/SwitchifyPc.App/DiagnosticsConsentWindow.xaml create mode 100644 src/SwitchifyPc.App/DiagnosticsConsentWindow.xaml.cs create mode 100644 src/SwitchifyPc.Core/Diagnostics/TelemetryReporter.cs create mode 100644 src/SwitchifyPc.Core/Diagnostics/TelemetrySanitizer.cs create mode 100644 src/SwitchifyPc.Core/Diagnostics/TelemetrySettings.cs create mode 100644 src/SwitchifyPc.Tests/TelemetryTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b758d8c..4ca9017 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,7 @@ jobs: SWITCHIFY_SIGNING_MODE: certum-simplysign SWITCHIFY_CERTUM_CERT_THUMBPRINT: ${{ vars.CERTUM_CERT_THUMBPRINT }} SWITCHIFY_CERTUM_TIMESTAMP_URL: http://time.certum.pl + TIMBERLOGS_API_KEY: ${{ secrets.TIMBERLOGS_API_KEY }} steps: - name: Resolve release tag @@ -55,6 +56,13 @@ jobs: - name: Restore run: dotnet restore src/SwitchifyPc.sln + - name: Verify Timberlogs configuration + shell: powershell + run: | + if ([string]::IsNullOrWhiteSpace($env:TIMBERLOGS_API_KEY)) { + throw 'TIMBERLOGS_API_KEY secret is not available to the release workflow.' + } + - name: Build run: dotnet build src/SwitchifyPc.sln -c Release --no-restore diff --git a/src/SwitchifyPc.App/App.xaml.cs b/src/SwitchifyPc.App/App.xaml.cs index 235f2ca..e1ca353 100644 --- a/src/SwitchifyPc.App/App.xaml.cs +++ b/src/SwitchifyPc.App/App.xaml.cs @@ -52,7 +52,13 @@ public partial class App : System.Windows.Application private readonly object bluetoothConnectionSync = new(); private readonly HashSet authenticatedBluetoothConnections = new(StringComparer.Ordinal); private DispatcherTimer? pairingExpiryTimer; + private DispatcherTimer? telemetryFlushTimer; private AppThemeManager? themeManager; + private JsonTelemetrySettingsStore? telemetrySettingsStore; + private ITelemetryReporter? telemetryReporter; + private readonly HashSet reportedExceptions = new(ReferenceEqualityComparer.Instance); + private readonly object reportedExceptionsSync = new(); + private bool diagnosticsConsentShown; private string? lastLoggedUpdateCheckStatus; private string? lastLoggedUpdateDownloadStatus; private bool isQuitting; @@ -82,6 +88,8 @@ protected override void OnStartup(StartupEventArgs e) return; } + InitializeTelemetry(); + existingInstanceSignal = new WindowsExistingInstanceSignal(); existingInstanceSignal.Start( () => Dispatcher.BeginInvoke(ShowMainWindow), @@ -94,6 +102,8 @@ protected override void OnStartup(StartupEventArgs e) RefreshPairingApprovals(); updateService.StartAutomaticUpdateChecks(); StartPairingExpiryTimer(); + StartTelemetryFlushTimer(); + _ = telemetryReporter?.FlushAsync(); _ = StartBluetoothAsync(); _ = InitializeStartupRegistrationAsync(e.Args, launchOptions.StartHidden); trayIcon = new NativeTrayIcon( @@ -123,6 +133,8 @@ protected override void OnExit(ExitEventArgs e) updateService = null; pairingExpiryTimer?.Stop(); pairingExpiryTimer = null; + telemetryFlushTimer?.Stop(); + telemetryFlushTimer = null; mouseRepeatController?.StopAllAsync().GetAwaiter().GetResult(); mouseRepeatController = null; bluetoothServer?.Dispose(); @@ -145,6 +157,9 @@ protected override void OnExit(ExitEventArgs e) trayIcon = null; settingsWindow = null; setupGuideWindow = null; + telemetryReporter?.Dispose(); + telemetryReporter = null; + telemetrySettingsStore = null; base.OnExit(e); } @@ -236,7 +251,9 @@ private SetupGuideWindow CreateSetupGuideWindow() AcceptPairingApprovalAsync, RejectPairingApproval, SetSetupStartWithSystemAsync, - CompleteSetupGuide); + CompleteSetupGuide, + () => Dispatcher.BeginInvoke(ShowDiagnosticsConsentIfNeeded), + setShareDiagnosticData: SetShareDiagnosticData); window.Closing += (_, eventArgs) => { if (isQuitting) return; @@ -276,7 +293,9 @@ private SettingsController CreateSettingsController() new JsonMouseRepeatSettingsStore(Path.Combine(userDataDirectory, "mouse-repeat-settings.json")), new JsonCursorOverlaySettingsStore(Path.Combine(userDataDirectory, "cursor-overlay-settings.json")), updateService ?? CreateUpdateService(), - new JsonPairingStore(Path.Combine(userDataDirectory, "pairing-state.json"))); + new JsonPairingStore(Path.Combine(userDataDirectory, "pairing-state.json")), + telemetrySettingsStore ?? CreateTelemetrySettingsStore(), + telemetryReporter ?? CreateDisabledTelemetryReporter()); } private SystemStartupService CreateStartupService() @@ -367,6 +386,7 @@ private async Task StartBluetoothAsync() { bluetoothStatusTracker?.SetError(error.Message); RecordRuntimeDiagnostic("bluetooth.start.failed", reason: "startup_failed"); + ReportException(error, "bluetooth"); } } @@ -551,6 +571,10 @@ await Dispatcher.BeginInvoke(() => MarkSetupGuideShown(); ShowSetupGuideWindowCore(); } + else + { + ShowDiagnosticsConsentIfNeeded(); + } }); } @@ -594,6 +618,70 @@ private void CompleteSetupGuide() store.Save(SetupGuidePrompt.MarkCompleted(settings)); } + private JsonTelemetrySettingsStore CreateTelemetrySettingsStore() + { + return new JsonTelemetrySettingsStore(Path.Combine(UserDataDirectory(), "telemetry-settings.json")); + } + + private ITelemetryReporter CreateDisabledTelemetryReporter() + { + return new TelemetryReporter(CreateTelemetrySettingsStore(), new HttpClient(), string.Empty, CurrentVersion, + Path.Combine(UserDataDirectory(), "telemetry-queue.json")); + } + + private void InitializeTelemetry() + { + telemetrySettingsStore = CreateTelemetrySettingsStore(); + string apiKey = typeof(App).Assembly.GetCustomAttributes() + .FirstOrDefault(attribute => attribute.Key == "TimberlogsApiKey")?.Value ?? string.Empty; + telemetryReporter = new TelemetryReporter( + telemetrySettingsStore, + new HttpClient(), + apiKey, + CurrentVersion, + Path.Combine(UserDataDirectory(), "telemetry-queue.json")); + + DispatcherUnhandledException += (_, args) => ReportException(args.Exception, "dispatcher"); + AppDomain.CurrentDomain.UnhandledException += (_, args) => + { + if (args.ExceptionObject is Exception error) ReportException(error, "appdomain"); + }; + TaskScheduler.UnobservedTaskException += (_, args) => ReportException(args.Exception, "task"); + } + + private void SetShareDiagnosticData(bool enabled) + { + telemetryReporter?.SetEnabled(enabled); + setupGuideViewModel.SetShareDiagnosticData(enabled); + if (enabled) _ = telemetryReporter?.FlushAsync(); + } + + private void ShowDiagnosticsConsentIfNeeded() + { + if (diagnosticsConsentShown || telemetrySettingsStore?.Load().ConsentRecorded != false || MainWindow is null) return; + diagnosticsConsentShown = true; + DiagnosticsConsentWindow prompt = new() { Owner = MainWindow }; + prompt.ShowDialog(); + SetShareDiagnosticData(prompt.Choice == true); + } + + private void ReportException(Exception error, string dataset) + { + lock (reportedExceptionsSync) + { + if (!reportedExceptions.Add(error)) return; + if (reportedExceptions.Count > 100) reportedExceptions.Clear(); + } + _ = telemetryReporter?.ReportExceptionAsync(error, dataset); + } + + private void StartTelemetryFlushTimer() + { + telemetryFlushTimer = new DispatcherTimer { Interval = TimeSpan.FromMinutes(5) }; + telemetryFlushTimer.Tick += (_, _) => _ = telemetryReporter?.FlushAsync(); + telemetryFlushTimer.Start(); + } + private void StartPairingExpiryTimer() { pairingExpiryTimer = new DispatcherTimer @@ -727,6 +815,23 @@ private void RecordRuntimeDiagnostic( Status: SafeDiagnosticText(status), Reason: SafeDiagnosticText(reason), Message: SafeDiagnosticText(message))); + + telemetryReporter?.RecordBreadcrumb(eventName); + if (IsRemoteHealthEvent(eventName)) + { + Dictionary data = []; + if (!string.IsNullOrWhiteSpace(status)) data["status"] = status; + if (!string.IsNullOrWhiteSpace(reason)) data["reason"] = reason; + _ = telemetryReporter?.ReportHealthAsync(eventName, data); + } + } + + private static bool IsRemoteHealthEvent(string eventName) + { + return eventName is "app.startup.completed" or "app.exit" or "bluetooth.ready" or + "bluetooth.unavailable" or "bluetooth.connected" or "bluetooth.disconnected" or + "bluetooth.error" or "bluetooth.start.failed" or "cursor.overlay.disabled" or + "startup.registration.repair.failed" or "update.state.changed"; } private static string? SafeDiagnosticText(string? value) diff --git a/src/SwitchifyPc.App/DiagnosticsConsentWindow.xaml b/src/SwitchifyPc.App/DiagnosticsConsentWindow.xaml new file mode 100644 index 0000000..619cf12 --- /dev/null +++ b/src/SwitchifyPc.App/DiagnosticsConsentWindow.xaml @@ -0,0 +1,29 @@ + + + + + + + + + + + + +