From 006a025c3d6133fc4353f31ed5aa1581f8eff444 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sat, 25 Jul 2026 08:12:17 +0300 Subject: [PATCH 1/2] Centralize telemetry schema and enforce validation --- .../ActivationTelemetryServiceTests.cs | 28 +-- .../Services/TelemetryEventCatalogTests.cs | 64 +++++ .../Services/TelemetryServiceTests.cs | 93 ++++++-- Pointframe/App.xaml.cs | 25 +- .../Services/Annotation/OcrLassoController.cs | 10 +- .../Services/Capture/CaptureLaunchService.cs | 42 +++- .../ActivationTelemetryService.cs | 24 +- .../Infrastructure/AppErrorHandler.cs | 4 +- .../Infrastructure/ITelemetryService.cs | 9 + .../Infrastructure/TelemetryEventCatalog.cs | 224 ++++++++++++++++++ .../TelemetryHeartbeatService.cs | 4 +- .../Infrastructure/TelemetryService.cs | 99 ++++++-- .../Infrastructure/TrayIconManager.cs | 14 +- .../Services/Update/AutoUpdateService.cs | 10 +- Pointframe/ViewModels/AnnotationViewModel.cs | 4 +- Pointframe/ViewModels/BeautifierViewModel.cs | 4 +- Pointframe/ViewModels/LibraryViewModel.cs | 17 +- Pointframe/ViewModels/OverlayViewModel.cs | 4 +- Pointframe/ViewModels/TrimViewModel.cs | 10 +- Pointframe/Views/OverlayWindow.Recording.cs | 10 +- 20 files changed, 587 insertions(+), 112 deletions(-) create mode 100644 Pointframe.Tests/Services/TelemetryEventCatalogTests.cs create mode 100644 Pointframe/Services/Infrastructure/TelemetryEventCatalog.cs diff --git a/Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs b/Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs index 3b0893f..ae7d1cb 100644 --- a/Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs +++ b/Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs @@ -33,18 +33,18 @@ public void TrackCaptureCompleted_TracksFirstCaptureOnlyOnce() sut.TrackCaptureCompleted("copy"); var eventNames = events.Select(item => item.Name).ToList(); - Assert.Equal(2, eventNames.Count(name => name == "capture_completed")); - Assert.Equal(1, eventNames.Count(name => name == "first_capture_completed")); + Assert.Equal(2, eventNames.Count(name => name == TelemetryEvents.CaptureCompleted)); + Assert.Equal(1, eventNames.Count(name => name == TelemetryEvents.FirstCaptureCompleted)); - var captureCompleted = events.First(item => item.Name == "capture_completed"); + var captureCompleted = events.First(item => item.Name == TelemetryEvents.CaptureCompleted); Assert.NotNull(captureCompleted.Props); - Assert.Equal("copy", captureCompleted.Props!["action"]); + Assert.Equal("copy", captureCompleted.Props![TelemetryPropertyKeys.Action]); - var firstCapture = events.Single(item => item.Name == "first_capture_completed"); + var firstCapture = events.Single(item => item.Name == TelemetryEvents.FirstCaptureCompleted); Assert.NotNull(firstCapture.Props); - Assert.Equal("screenshot", firstCapture.Props!["capture_type"]); - Assert.Equal("copy", firstCapture.Props["first_action"]); - Assert.True(firstCapture.Props.ContainsKey("time_from_install_minutes")); + Assert.Equal("screenshot", firstCapture.Props![TelemetryPropertyKeys.CaptureType]); + Assert.Equal("copy", firstCapture.Props[TelemetryPropertyKeys.FirstAction]); + Assert.True(firstCapture.Props.ContainsKey(TelemetryPropertyKeys.TimeFromInstallMinutes)); } [Fact] @@ -74,13 +74,13 @@ public void TrackRecordingCompleted_TracksFirstRecordingOnlyOnceAndIncludesDurat sut.TrackRecordingCompleted("01:05"); var eventNames = events.Select(item => item.Name).ToList(); - Assert.Equal(2, eventNames.Count(name => name == "recording_completed")); - Assert.Equal(1, eventNames.Count(name => name == "first_recording_completed")); + Assert.Equal(2, eventNames.Count(name => name == TelemetryEvents.RecordingCompleted)); + Assert.Equal(1, eventNames.Count(name => name == TelemetryEvents.FirstRecordingCompleted)); - var firstRecording = events.Single(item => item.Name == "first_recording_completed"); + var firstRecording = events.Single(item => item.Name == TelemetryEvents.FirstRecordingCompleted); Assert.NotNull(firstRecording.Props); - Assert.Equal("true", firstRecording.Props!["with_audio"]); - Assert.Equal("65", firstRecording.Props["duration_seconds"]); - Assert.True(firstRecording.Props.ContainsKey("time_from_install_minutes")); + Assert.Equal("true", firstRecording.Props![TelemetryPropertyKeys.WithAudio]); + Assert.Equal("65", firstRecording.Props[TelemetryPropertyKeys.DurationSeconds]); + Assert.True(firstRecording.Props.ContainsKey(TelemetryPropertyKeys.TimeFromInstallMinutes)); } } diff --git a/Pointframe.Tests/Services/TelemetryEventCatalogTests.cs b/Pointframe.Tests/Services/TelemetryEventCatalogTests.cs new file mode 100644 index 0000000..6dd75e9 --- /dev/null +++ b/Pointframe.Tests/Services/TelemetryEventCatalogTests.cs @@ -0,0 +1,64 @@ +using Pointframe.Services; +using Xunit; + +namespace Pointframe.Tests.Services; + +public sealed class TelemetryEventCatalogTests +{ + [Fact] + public void Validate_WhenKnownEventIncludesRequiredProperties_ReturnsValid() + { + foreach (var definition in TelemetryEventCatalog.All) + { + var properties = definition.RequiredProperties.ToDictionary( + key => key, + _ => "value", + StringComparer.Ordinal); + + var result = TelemetryEventCatalog.Validate(definition.Name, properties); + + Assert.True(result.IsValid, $"Expected event '{definition.Name}' to validate."); + Assert.True(result.IsKnownEvent); + Assert.NotNull(result.Definition); + Assert.Empty(result.MissingProperties); + } + } + + [Fact] + public void Validate_WhenKnownEventMissesRequiredProperty_ReturnsMissingPropertyResult() + { + var props = new Dictionary + { + [TelemetryPropertyKeys.Type] = "region", + }; + + var result = TelemetryEventCatalog.Validate(TelemetryEvents.SnipStarted, props); + + Assert.False(result.IsValid); + Assert.True(result.IsKnownEvent); + Assert.Contains(TelemetryPropertyKeys.Source, result.MissingProperties); + } + + [Fact] + public void Validate_WhenEventIsUnknown_ReturnsUnknownEventResult() + { + var result = TelemetryEventCatalog.Validate("not_registered", null); + + Assert.False(result.IsValid); + Assert.False(result.IsKnownEvent); + Assert.Null(result.Definition); + Assert.Empty(result.MissingProperties); + } + + [Fact] + public void Catalog_ContainsExpectedDiagnosticEvents() + { + Assert.True(TelemetryEventCatalog.TryGetDefinition(TelemetryEvents.AppHeartbeat, out var heartbeat)); + Assert.True(TelemetryEventCatalog.TryGetDefinition(TelemetryEvents.StartupCompleted, out var startup)); + Assert.True(TelemetryEventCatalog.TryGetDefinition(TelemetryEvents.UnhandledException, out var exception)); + + Assert.Equal(TelemetryChannel.Diagnostic, heartbeat.Channel); + Assert.Equal(TelemetryChannel.Diagnostic, startup.Channel); + Assert.Equal(TelemetryChannel.Diagnostic, exception.Channel); + } +} diff --git a/Pointframe.Tests/Services/TelemetryServiceTests.cs b/Pointframe.Tests/Services/TelemetryServiceTests.cs index 185d445..56d2cce 100644 --- a/Pointframe.Tests/Services/TelemetryServiceTests.cs +++ b/Pointframe.Tests/Services/TelemetryServiceTests.cs @@ -119,12 +119,55 @@ public void TrackEvent_LogsOneEntry() var sut = CreateSut(logger); // Act - sut.TrackEvent("snip_started"); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Assert Assert.Single(logger.Entries); } + [Fact] + public void TrackEvent_KnownProductEvent_IncludesProductChannelInScope() + { + var logger = new CapturingLogger(); + var sut = CreateSut(logger); + + sut.TrackEvent(TelemetryEvents.SnipStarted, new Dictionary + { + [TelemetryPropertyKeys.Type] = "region", + [TelemetryPropertyKeys.Source] = "hotkey", + }); + + Assert.Equal("product", logger.Entries[0].Scope["telemetry_channel"]); + } + + [Fact] + public void TrackEvent_KnownDiagnosticEvent_IncludesDiagnosticChannelInScope() + { + var logger = new CapturingLogger(); + var sut = CreateSut(logger); + + sut.TrackEvent(TelemetryEvents.AppHeartbeat, new Dictionary + { + [TelemetryPropertyKeys.UptimeMinutes] = "30", + }); + + Assert.Equal("diagnostic", logger.Entries[0].Scope["telemetry_channel"]); + } + + [Fact] + public void TrackEvent_WhenRequiredPropertiesMissing_LogsSchemaWarning() + { + var logger = new CapturingLogger(); + var sut = CreateSut(logger); + + sut.TrackEvent(TelemetryEvents.SnipStarted, new Dictionary + { + [TelemetryPropertyKeys.Type] = "region", + }); + + Assert.Contains(logger.Entries, entry => entry.Level == LogLevel.Warning); + } + [Fact] public void TrackEvent_MessageContainsEventName() { @@ -133,10 +176,10 @@ public void TrackEvent_MessageContainsEventName() var sut = CreateSut(logger); // Act - sut.TrackEvent("snip_started"); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Assert - Assert.Contains("snip_started", logger.Entries[0].Message); + Assert.Contains(TelemetryEvents.CapturePinned, logger.Entries[0].Message); } [Fact] @@ -161,7 +204,7 @@ public void TrackEvent_IncludesInstallIdInScope() var sut = CreateSut(logger, installId: "abc123"); // Act - sut.TrackEvent("annotation_committed"); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Assert Assert.Equal("abc123", logger.Entries[0].Scope["install_id"]); @@ -175,7 +218,7 @@ public void TrackEvent_IncludesVersionInScope() var sut = new TelemetryService(logger, SettingsWithInstallId("abc123"), AppVersion(new Version(9, 8, 7))); // Act - sut.TrackEvent("annotation_committed"); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Assert Assert.Equal("9.8.7", logger.Entries[0].Scope["version"]); @@ -189,7 +232,7 @@ public void TrackEvent_OmitsInstallIdWhenNull() var sut = CreateSut(logger, installId: null); // Act - sut.TrackEvent("annotation_committed"); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Assert Assert.DoesNotContain("install_id", logger.Entries[0].Scope.Keys); @@ -203,7 +246,7 @@ public void TrackEvent_OmitsInstallIdWhenEmpty() var sut = CreateSut(logger, installId: string.Empty); // Act - sut.TrackEvent("annotation_committed"); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Assert Assert.DoesNotContain("install_id", logger.Entries[0].Scope.Keys); @@ -217,7 +260,11 @@ public void TrackEvent_IncludesAdditionalPropertiesInScope() var sut = CreateSut(logger); // Act - sut.TrackEvent("snip_started", new Dictionary { ["type"] = "region" }); + sut.TrackEvent(TelemetryEvents.SnipStarted, new Dictionary + { + [TelemetryPropertyKeys.Type] = "region", + [TelemetryPropertyKeys.Source] = "tray", + }); // Assert Assert.Equal("region", logger.Entries[0].Scope["type"]); @@ -231,7 +278,10 @@ public void TrackEvent_AdditionalPropertiesCoexistWithInstallId() var sut = CreateSut(logger, installId: "xyz"); // Act - sut.TrackEvent("recording_started", new Dictionary { ["type"] = "whole_screen" }); + sut.TrackEvent(TelemetryEvents.RecordingStarted, new Dictionary + { + [TelemetryPropertyKeys.Type] = "whole_screen", + }); // Assert var scope = logger.Entries[0].Scope; @@ -253,6 +303,17 @@ public void TrackException_LogsOneEntry() Assert.Single(logger.Entries); } + [Fact] + public void TrackDiagnosticException_LogsDiagnosticChannelInScope() + { + var logger = new CapturingLogger(); + var sut = CreateSut(logger); + + sut.TrackDiagnosticException(new InvalidOperationException("boom"), "dispatcher"); + + Assert.Equal("diagnostic", logger.Entries[0].Scope["telemetry_channel"]); + } + [Fact] public void TrackException_LogsAtErrorLevel() { @@ -359,7 +420,7 @@ public void TrackEvent_IncludesSessionIdInScope() var sut = CreateSut(logger); // Act - sut.TrackEvent("snip_started"); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Assert Assert.True(logger.Entries[0].Scope.ContainsKey("session_id")); @@ -374,8 +435,8 @@ public void TrackEvent_SessionIdIsConsistentAcrossEvents() var sut = CreateSut(logger); // Act - sut.TrackEvent("snip_started"); - sut.TrackEvent("capture_completed"); + sut.TrackEvent(TelemetryEvents.CapturePinned); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Assert var first = logger.Entries[0].Scope["session_id"]; @@ -392,8 +453,8 @@ public void TrackEvent_SessionIdDiffersAcrossInstances() var sut2 = CreateSut(logger); // Act - sut1.TrackEvent("snip_started"); - sut2.TrackEvent("snip_started"); + sut1.TrackEvent(TelemetryEvents.CapturePinned); + sut2.TrackEvent(TelemetryEvents.CapturePinned); // Assert Assert.NotEqual(logger.Entries[0].Scope["session_id"], logger.Entries[1].Scope["session_id"]); @@ -405,13 +466,13 @@ public void TrackException_IncludesLastActionWhenEventWasPreviouslyTracked() // Arrange var logger = new CapturingLogger(); var sut = CreateSut(logger); - sut.TrackEvent("annotation_committed"); + sut.TrackEvent(TelemetryEvents.CapturePinned); // Act sut.TrackException(new InvalidOperationException("boom")); // Assert - Assert.Equal("annotation_committed", logger.Entries[1].Scope["last_action"]); + Assert.Equal(TelemetryEvents.CapturePinned, logger.Entries[1].Scope[TelemetryPropertyKeys.LastAction]); } [Fact] diff --git a/Pointframe/App.xaml.cs b/Pointframe/App.xaml.cs index fb660ac..d207dab 100644 --- a/Pointframe/App.xaml.cs +++ b/Pointframe/App.xaml.cs @@ -123,11 +123,11 @@ protected override void OnStartup(StartupEventArgs e) { var version = _host.Services.GetRequiredService().Current; _sessionStartTime = DateTime.UtcNow; - _telemetry.TrackEvent("app_started", new Dictionary + _telemetry.TrackEvent(TelemetryEvents.AppStarted, new Dictionary { - ["version"] = version.ToString(), - ["os_build"] = Environment.OSVersion.Version.ToString(), - ["screen_count"] = System.Windows.Forms.Screen.AllScreens.Length.ToString(), + [TelemetryPropertyKeys.Version] = version.ToString(), + [TelemetryPropertyKeys.OsBuild] = Environment.OSVersion.Version.ToString(), + [TelemetryPropertyKeys.ScreenCount] = System.Windows.Forms.Screen.AllScreens.Length.ToString(), }); } @@ -143,9 +143,9 @@ protected override void OnStartup(StartupEventArgs e) _trayIconManager = _host.Services.GetRequiredService(); _trayIconManager.Initialize(); startupTimer.Stop(); - _telemetry.TrackEvent("startup_completed", new Dictionary + _telemetry.TrackEvent(TelemetryEvents.StartupCompleted, new Dictionary { - ["duration_ms"] = startupTimer.ElapsedMilliseconds.ToString(), + [TelemetryPropertyKeys.DurationMilliseconds] = startupTimer.ElapsedMilliseconds.ToString(), }); #if DEBUG _trayIconManager.AddDebugMenuItems(); @@ -171,9 +171,9 @@ protected override void OnExit(ExitEventArgs e) _logger?.LogInformation("Pointframe shutting down"); if (!_isAutomationMode && _sessionStartTime != default) { - _telemetry?.TrackEvent("app_closed", new Dictionary + _telemetry?.TrackEvent(TelemetryEvents.AppClosed, new Dictionary { - ["session_minutes"] = ((int)(DateTime.UtcNow - _sessionStartTime).TotalMinutes).ToString(), + [TelemetryPropertyKeys.SessionMinutes] = ((int)(DateTime.UtcNow - _sessionStartTime).TotalMinutes).ToString(), }); } @@ -251,7 +251,7 @@ private void OpenImage() try { var bitmap = _imageFileService.LoadForAnnotation(selectedPath); - _telemetry.TrackEvent("open_image_used"); + _telemetry.TrackEvent(TelemetryEvents.OpenImageUsed); ShowOverlayFromImage(bitmap, selectedPath); } catch (Exception ex) when (ex is FileNotFoundException or InvalidDataException or NotSupportedException or IOException or UnauthorizedAccessException) @@ -312,7 +312,7 @@ private void OpenCaptureFromLibrary(CaptureItem item) try { var bitmap = _imageFileService.LoadForAnnotation(item.FilePath); - _telemetry.TrackEvent("library_open_used"); + _telemetry.TrackEvent(TelemetryEvents.LibraryOpenUsed); // Close the library before the full-screen overlay appears so the two never overlap. _libraryWindow?.Close(); @@ -398,7 +398,10 @@ private async ValueTask HandleUpdateAvailable(UpdateAvailableMessage message) } var v = message.Result.LatestVersion; - _telemetry.TrackEvent("update_available", new Dictionary { ["version"] = $"{v.Major}.{v.Minor}.{v.Build}" }); + _telemetry.TrackEvent(TelemetryEvents.UpdateAvailable, new Dictionary + { + [TelemetryPropertyKeys.Version] = $"{v.Major}.{v.Minor}.{v.Build}", + }); } private ValueTask HandleOpenImageRequested(OpenImageRequestedMessage message) diff --git a/Pointframe/Services/Annotation/OcrLassoController.cs b/Pointframe/Services/Annotation/OcrLassoController.cs index 1ac946b..87c65c6 100644 --- a/Pointframe/Services/Annotation/OcrLassoController.cs +++ b/Pointframe/Services/Annotation/OcrLassoController.cs @@ -140,22 +140,22 @@ internal async Task RecognizeAsync(Rect lassoRect) var cropped = new CroppedBitmap(background, new Int32Rect(pixelX, pixelY, pixelW, pixelH)); var ocrProps = new Dictionary { - ["selection_width_px"] = pixelW.ToString(), - ["selection_height_px"] = pixelH.ToString(), + [TelemetryPropertyKeys.SelectionWidthPx] = pixelW.ToString(), + [TelemetryPropertyKeys.SelectionHeightPx] = pixelH.ToString(), }; - _telemetry.TrackEvent("ocr_attempted", ocrProps); + _telemetry.TrackEvent(TelemetryEvents.OcrAttempted, ocrProps); var text = await _ocrService.Recognize(cropped); if (string.IsNullOrWhiteSpace(text)) { - _telemetry.TrackEvent("ocr_no_text", ocrProps); + _telemetry.TrackEvent(TelemetryEvents.OcrNoText, ocrProps); _showToast("No text detected — try a larger area"); return; } System.Windows.Clipboard.SetText(text); - _telemetry.TrackEvent("ocr_used", ocrProps); + _telemetry.TrackEvent(TelemetryEvents.OcrUsed, ocrProps); _showToast("✓ Text copied to clipboard"); } } diff --git a/Pointframe/Services/Capture/CaptureLaunchService.cs b/Pointframe/Services/Capture/CaptureLaunchService.cs index aba2aa6..47a1a6a 100644 --- a/Pointframe/Services/Capture/CaptureLaunchService.cs +++ b/Pointframe/Services/Capture/CaptureLaunchService.cs @@ -43,26 +43,41 @@ public CaptureLaunchService( public void StartRegionSnip(string source = "tray") { _logger.LogDebug("Region snip started"); - _telemetry.TrackEvent("snip_started", new Dictionary { ["type"] = "region", ["source"] = source }); + _telemetry.TrackEvent(TelemetryEvents.SnipStarted, new Dictionary + { + [TelemetryPropertyKeys.Type] = "region", + [TelemetryPropertyKeys.Source] = source, + }); LaunchCapture(wholeScreen: false); } public void StartWholeScreenSnip(string source = "tray") { _logger.LogDebug("Whole-screen snip started"); - _telemetry.TrackEvent("snip_started", new Dictionary { ["type"] = "whole_screen", ["source"] = source }); + _telemetry.TrackEvent(TelemetryEvents.SnipStarted, new Dictionary + { + [TelemetryPropertyKeys.Type] = "whole_screen", + [TelemetryPropertyKeys.Source] = source, + }); LaunchCapture(wholeScreen: true); } public void StartCleanWindowSnip(string source = "tray") { _logger.LogDebug("Clean window snip started"); - _telemetry.TrackEvent("snip_started", new Dictionary { ["type"] = "window_clean", ["source"] = source }); + _telemetry.TrackEvent(TelemetryEvents.SnipStarted, new Dictionary + { + [TelemetryPropertyKeys.Type] = "window_clean", + [TelemetryPropertyKeys.Source] = source, + }); var delay = _userSettings.Current.CaptureDelaySeconds; if (delay > 0) { - _telemetry.TrackEvent("capture_delay_used", new Dictionary { ["delay_seconds"] = delay.ToString() }); + _telemetry.TrackEvent(TelemetryEvents.CaptureDelayUsed, new Dictionary + { + [TelemetryPropertyKeys.DelaySeconds] = delay.ToString(), + }); new CountdownWindow(delay, () => ExecuteCleanWindowSnip()).Show(); return; } @@ -101,7 +116,10 @@ private void LaunchCapture(bool wholeScreen) var delay = _userSettings.Current.CaptureDelaySeconds; if (delay > 0) { - _telemetry.TrackEvent("capture_delay_used", new Dictionary { ["delay_seconds"] = delay.ToString() }); + _telemetry.TrackEvent(TelemetryEvents.CaptureDelayUsed, new Dictionary + { + [TelemetryPropertyKeys.DelaySeconds] = delay.ToString(), + }); new CountdownWindow(delay, () => ShowSelectionOverlay(wholeScreen)).Show(); return; } @@ -118,7 +136,10 @@ private async void ShowSelectionOverlay(bool wholeScreen) if (selection is null) { - _telemetry.TrackEvent("snip_cancelled", new Dictionary { ["type"] = wholeScreen ? "whole_screen" : "region" }); + _telemetry.TrackEvent(TelemetryEvents.SnipCancelled, new Dictionary + { + [TelemetryPropertyKeys.Type] = wholeScreen ? "whole_screen" : "region", + }); return; } @@ -167,16 +188,19 @@ await System.Threading.Tasks.Task.Run(() => recorder.Start( } catch (FileNotFoundException ex) { - _telemetry.TrackEvent("ffmpeg_missing"); + _telemetry.TrackEvent(TelemetryEvents.FfmpegMissing); _messageBox.ShowWarning(ex.Message, "ffmpeg not found"); return; } - _telemetry.TrackEvent("recording_started", new Dictionary { ["type"] = "whole_screen" }); + _telemetry.TrackEvent(TelemetryEvents.RecordingStarted, new Dictionary + { + [TelemetryPropertyKeys.Type] = "whole_screen", + }); if (_userSettings.Current.RecordMicrophone && !recorder.IsRecordingMicrophoneEnabled) { - _telemetry.TrackEvent("microphone_unavailable"); + _telemetry.TrackEvent(TelemetryEvents.MicrophoneUnavailable); _messageBox.ShowWarning( "Microphone recording is enabled, but no compatible microphone device was available. The recording will continue without microphone audio.", "Microphone unavailable"); diff --git a/Pointframe/Services/Infrastructure/ActivationTelemetryService.cs b/Pointframe/Services/Infrastructure/ActivationTelemetryService.cs index 285846c..4c30476 100644 --- a/Pointframe/Services/Infrastructure/ActivationTelemetryService.cs +++ b/Pointframe/Services/Infrastructure/ActivationTelemetryService.cs @@ -15,9 +15,9 @@ public ActivationTelemetryService( public void TrackCaptureCompleted(string captureAction) { - _telemetry.TrackEvent("capture_completed", new Dictionary + _telemetry.TrackEvent(TelemetryEvents.CaptureCompleted, new Dictionary { - ["action"] = captureAction, + [TelemetryPropertyKeys.Action] = captureAction, }); var shouldTrackFirstCapture = false; @@ -42,16 +42,16 @@ public void TrackCaptureCompleted(string captureAction) var props = new Dictionary { - ["capture_type"] = "screenshot", - ["first_action"] = captureAction, + [TelemetryPropertyKeys.CaptureType] = "screenshot", + [TelemetryPropertyKeys.FirstAction] = captureAction, }; if (timeFromInstallMinutes is not null) { - props["time_from_install_minutes"] = timeFromInstallMinutes.Value.ToString(); + props[TelemetryPropertyKeys.TimeFromInstallMinutes] = timeFromInstallMinutes.Value.ToString(); } - _telemetry.TrackEvent("first_capture_completed", props); + _telemetry.TrackEvent(TelemetryEvents.FirstCaptureCompleted, props); } public void TrackRecordingCompleted(string elapsedText) @@ -63,11 +63,11 @@ public void TrackRecordingCompleted(string elapsedText) { recordingProps = new Dictionary { - ["duration_seconds"] = durationSeconds.Value.ToString(), + [TelemetryPropertyKeys.DurationSeconds] = durationSeconds.Value.ToString(), }; } - _telemetry.TrackEvent("recording_completed", recordingProps); + _telemetry.TrackEvent(TelemetryEvents.RecordingCompleted, recordingProps); var shouldTrackFirstRecording = false; int? timeFromInstallMinutes = null; @@ -93,20 +93,20 @@ public void TrackRecordingCompleted(string elapsedText) var firstRecordingProps = new Dictionary { - ["with_audio"] = withAudio ? "true" : "false", + [TelemetryPropertyKeys.WithAudio] = withAudio ? "true" : "false", }; if (durationSeconds is not null) { - firstRecordingProps["duration_seconds"] = durationSeconds.Value.ToString(); + firstRecordingProps[TelemetryPropertyKeys.DurationSeconds] = durationSeconds.Value.ToString(); } if (timeFromInstallMinutes is not null) { - firstRecordingProps["time_from_install_minutes"] = timeFromInstallMinutes.Value.ToString(); + firstRecordingProps[TelemetryPropertyKeys.TimeFromInstallMinutes] = timeFromInstallMinutes.Value.ToString(); } - _telemetry.TrackEvent("first_recording_completed", firstRecordingProps); + _telemetry.TrackEvent(TelemetryEvents.FirstRecordingCompleted, firstRecordingProps); } private static int? TryGetDurationSeconds(string elapsedText) diff --git a/Pointframe/Services/Infrastructure/AppErrorHandler.cs b/Pointframe/Services/Infrastructure/AppErrorHandler.cs index d41fe61..c9ed9c6 100644 --- a/Pointframe/Services/Infrastructure/AppErrorHandler.cs +++ b/Pointframe/Services/Infrastructure/AppErrorHandler.cs @@ -28,7 +28,7 @@ public void Register() private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { _logger.LogError(e.Exception, "Unhandled dispatcher exception"); - _telemetry.TrackException(e.Exception, "dispatcher"); + _telemetry.TrackDiagnosticException(e.Exception, "dispatcher"); e.Handled = true; var closedWindowName = TryRecoverFromActiveWindow(); @@ -108,7 +108,7 @@ private void OnAppDomainUnhandledException(object sender, UnhandledExceptionEven private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) { _logger.LogError(e.Exception, "Unobserved task exception"); - _telemetry.TrackException(e.Exception, "task"); + _telemetry.TrackDiagnosticException(e.Exception, "task"); e.SetObserved(); } } diff --git a/Pointframe/Services/Infrastructure/ITelemetryService.cs b/Pointframe/Services/Infrastructure/ITelemetryService.cs index d4de05f..b377cdb 100644 --- a/Pointframe/Services/Infrastructure/ITelemetryService.cs +++ b/Pointframe/Services/Infrastructure/ITelemetryService.cs @@ -2,6 +2,15 @@ namespace Pointframe.Services; public interface ITelemetryService { + void TrackProductEvent(string name, IReadOnlyDictionary? properties = null); + + void TrackDiagnosticEvent(string name, IReadOnlyDictionary? properties = null); + + void TrackDiagnosticException( + Exception exception, + string? context = null, + IReadOnlyDictionary? properties = null); + void TrackEvent(string name, IReadOnlyDictionary? properties = null); void TrackException(Exception exception, string? context = null); diff --git a/Pointframe/Services/Infrastructure/TelemetryEventCatalog.cs b/Pointframe/Services/Infrastructure/TelemetryEventCatalog.cs new file mode 100644 index 0000000..2e68059 --- /dev/null +++ b/Pointframe/Services/Infrastructure/TelemetryEventCatalog.cs @@ -0,0 +1,224 @@ +namespace Pointframe.Services; + +public enum TelemetryChannel +{ + Product, + Diagnostic, +} + +public static class TelemetryPropertyKeys +{ + public const string Action = "action"; + public const string AppVersion = "version"; + public const string Canceled = "canceled"; + public const string CaptureType = "capture_type"; + public const string Context = "context"; + public const string DelaySeconds = "delay_seconds"; + public const string DurationMilliseconds = "duration_ms"; + public const string DurationSeconds = "duration_seconds"; + public const string ExceptionType = "exception_type"; + public const string FirstAction = "first_action"; + public const string LastAction = "last_action"; + public const string OsBuild = "os_build"; + public const string ScreenCount = "screen_count"; + public const string SelectionHeightPx = "selection_height_px"; + public const string SelectionWidthPx = "selection_width_px"; + public const string SessionMinutes = "session_minutes"; + public const string Source = "source"; + public const string Success = "success"; + public const string TimeFromInstallMinutes = "time_from_install_minutes"; + public const string Tool = "tool"; + public const string Type = "type"; + public const string UptimeMinutes = "uptime_minutes"; + public const string Version = "version"; + public const string WithAudio = "with_audio"; +} + +public static class TelemetryEvents +{ + public const string AnnotationCommitted = "annotation_committed"; + public const string AppClosed = "app_closed"; + public const string AppHeartbeat = "app_heartbeat"; + public const string AppStarted = "app_started"; + public const string BeautifyOpened = "beautify_opened"; + public const string CaptureCompleted = "capture_completed"; + public const string CaptureDelayUsed = "capture_delay_used"; + public const string CapturePinned = "capture_pinned"; + public const string FirstCaptureCompleted = "first_capture_completed"; + public const string FirstRecordingCompleted = "first_recording_completed"; + public const string FfmpegMissing = "ffmpeg_missing"; + public const string GifExportCompleted = "gif_export_completed"; + public const string GifExportStarted = "gif_export_started"; + public const string LibraryOcrSearchUsed = "library_ocr_search_used"; + public const string LibraryOpenUsed = "library_open_used"; + public const string MicrophoneUnavailable = "microphone_unavailable"; + public const string OcrAttempted = "ocr_attempted"; + public const string OcrNoText = "ocr_no_text"; + public const string OcrUsed = "ocr_used"; + public const string OpenImageUsed = "open_image_used"; + public const string RecordingCompleted = "recording_completed"; + public const string RecordingStarted = "recording_started"; + public const string ScreenshotBeautified = "screenshot_beautified"; + public const string ScreenshotBeautifiedCopied = "screenshot_beautified_copied"; + public const string SnipCancelled = "snip_cancelled"; + public const string SnipStarted = "snip_started"; + public const string StartupCompleted = "startup_completed"; + public const string UnhandledException = "unhandled_exception"; + public const string UpdateAvailable = "update_available"; + public const string UpdateCheckManual = "update_check_manual"; + public const string UpdateConfirmed = "update_confirmed"; + public const string UpdateDismissed = "update_dismissed"; + public const string VideoTrimCompleted = "video_trim_completed"; + public const string VideoTrimOpened = "video_trim_opened"; + public const string VideoTrimStarted = "video_trim_started"; +} + +public sealed class TelemetryEventDefinition +{ + public TelemetryEventDefinition( + string name, + TelemetryChannel channel, + params string[] requiredProperties) + { + Name = name; + Channel = channel; + RequiredProperties = requiredProperties; + } + + public string Name { get; } + + public TelemetryChannel Channel { get; } + + public IReadOnlyList RequiredProperties { get; } +} + +public static class TelemetryEventCatalog +{ + private static readonly IReadOnlyDictionary Definitions = + new Dictionary(StringComparer.Ordinal) + { + [TelemetryEvents.AnnotationCommitted] = Product(TelemetryEvents.AnnotationCommitted, TelemetryPropertyKeys.Tool), + [TelemetryEvents.AppClosed] = Product(TelemetryEvents.AppClosed, TelemetryPropertyKeys.SessionMinutes), + [TelemetryEvents.AppHeartbeat] = Diagnostic(TelemetryEvents.AppHeartbeat, TelemetryPropertyKeys.UptimeMinutes), + [TelemetryEvents.AppStarted] = Product(TelemetryEvents.AppStarted, TelemetryPropertyKeys.OsBuild, TelemetryPropertyKeys.ScreenCount), + [TelemetryEvents.BeautifyOpened] = Product(TelemetryEvents.BeautifyOpened), + [TelemetryEvents.CaptureCompleted] = Product(TelemetryEvents.CaptureCompleted, TelemetryPropertyKeys.Action), + [TelemetryEvents.CaptureDelayUsed] = Product(TelemetryEvents.CaptureDelayUsed, TelemetryPropertyKeys.DelaySeconds), + [TelemetryEvents.CapturePinned] = Product(TelemetryEvents.CapturePinned), + [TelemetryEvents.FirstCaptureCompleted] = Product(TelemetryEvents.FirstCaptureCompleted, TelemetryPropertyKeys.CaptureType, TelemetryPropertyKeys.FirstAction), + [TelemetryEvents.FirstRecordingCompleted] = Product(TelemetryEvents.FirstRecordingCompleted, TelemetryPropertyKeys.WithAudio), + [TelemetryEvents.FfmpegMissing] = Diagnostic(TelemetryEvents.FfmpegMissing), + [TelemetryEvents.GifExportCompleted] = Product(TelemetryEvents.GifExportCompleted, TelemetryPropertyKeys.Success, TelemetryPropertyKeys.DurationSeconds), + [TelemetryEvents.GifExportStarted] = Product(TelemetryEvents.GifExportStarted), + [TelemetryEvents.LibraryOcrSearchUsed] = Product(TelemetryEvents.LibraryOcrSearchUsed), + [TelemetryEvents.LibraryOpenUsed] = Product(TelemetryEvents.LibraryOpenUsed), + [TelemetryEvents.MicrophoneUnavailable] = Diagnostic(TelemetryEvents.MicrophoneUnavailable), + [TelemetryEvents.OcrAttempted] = Product(TelemetryEvents.OcrAttempted, TelemetryPropertyKeys.SelectionWidthPx, TelemetryPropertyKeys.SelectionHeightPx), + [TelemetryEvents.OcrNoText] = Product(TelemetryEvents.OcrNoText, TelemetryPropertyKeys.SelectionWidthPx, TelemetryPropertyKeys.SelectionHeightPx), + [TelemetryEvents.OcrUsed] = Product(TelemetryEvents.OcrUsed, TelemetryPropertyKeys.SelectionWidthPx, TelemetryPropertyKeys.SelectionHeightPx), + [TelemetryEvents.OpenImageUsed] = Product(TelemetryEvents.OpenImageUsed), + [TelemetryEvents.RecordingCompleted] = Product(TelemetryEvents.RecordingCompleted), + [TelemetryEvents.RecordingStarted] = Product(TelemetryEvents.RecordingStarted, TelemetryPropertyKeys.Type), + [TelemetryEvents.ScreenshotBeautified] = Product(TelemetryEvents.ScreenshotBeautified), + [TelemetryEvents.ScreenshotBeautifiedCopied] = Product(TelemetryEvents.ScreenshotBeautifiedCopied), + [TelemetryEvents.SnipCancelled] = Product(TelemetryEvents.SnipCancelled, TelemetryPropertyKeys.Type), + [TelemetryEvents.SnipStarted] = Product(TelemetryEvents.SnipStarted, TelemetryPropertyKeys.Type, TelemetryPropertyKeys.Source), + [TelemetryEvents.StartupCompleted] = Diagnostic(TelemetryEvents.StartupCompleted, TelemetryPropertyKeys.DurationMilliseconds), + [TelemetryEvents.UnhandledException] = Diagnostic(TelemetryEvents.UnhandledException, TelemetryPropertyKeys.ExceptionType), + [TelemetryEvents.UpdateAvailable] = Product(TelemetryEvents.UpdateAvailable, TelemetryPropertyKeys.Version), + [TelemetryEvents.UpdateCheckManual] = Product(TelemetryEvents.UpdateCheckManual), + [TelemetryEvents.UpdateConfirmed] = Product(TelemetryEvents.UpdateConfirmed, TelemetryPropertyKeys.Version), + [TelemetryEvents.UpdateDismissed] = Product(TelemetryEvents.UpdateDismissed, TelemetryPropertyKeys.Version), + [TelemetryEvents.VideoTrimCompleted] = Product(TelemetryEvents.VideoTrimCompleted, TelemetryPropertyKeys.Success, TelemetryPropertyKeys.Canceled), + [TelemetryEvents.VideoTrimOpened] = Product(TelemetryEvents.VideoTrimOpened), + [TelemetryEvents.VideoTrimStarted] = Product(TelemetryEvents.VideoTrimStarted), + }; + + public static IReadOnlyCollection All => Definitions.Values.ToArray(); + + public static bool TryGetDefinition(string eventName, out TelemetryEventDefinition definition) + { + return Definitions.TryGetValue(eventName, out definition!); + } + + public static TelemetrySchemaValidationResult Validate( + string eventName, + IReadOnlyDictionary? properties) + { + if (!TryGetDefinition(eventName, out var definition)) + { + return TelemetrySchemaValidationResult.UnknownEvent(eventName); + } + + if (definition.RequiredProperties.Count == 0) + { + return TelemetrySchemaValidationResult.Valid(definition); + } + + if (properties is null) + { + return TelemetrySchemaValidationResult.MissingRequiredProperties(definition, definition.RequiredProperties); + } + + var missing = definition.RequiredProperties + .Where(requiredProperty => !properties.ContainsKey(requiredProperty)) + .ToArray(); + return missing.Length == 0 + ? TelemetrySchemaValidationResult.Valid(definition) + : TelemetrySchemaValidationResult.MissingRequiredProperties(definition, missing); + } + + private static TelemetryEventDefinition Product(string name, params string[] requiredProperties) + { + return new TelemetryEventDefinition(name, TelemetryChannel.Product, requiredProperties); + } + + private static TelemetryEventDefinition Diagnostic(string name, params string[] requiredProperties) + { + return new TelemetryEventDefinition(name, TelemetryChannel.Diagnostic, requiredProperties); + } +} + +public sealed class TelemetrySchemaValidationResult +{ + private TelemetrySchemaValidationResult( + bool isValid, + bool isKnownEvent, + TelemetryEventDefinition? definition, + IReadOnlyList missingProperties, + string eventName) + { + IsValid = isValid; + IsKnownEvent = isKnownEvent; + Definition = definition; + MissingProperties = missingProperties; + EventName = eventName; + } + + public bool IsValid { get; } + + public bool IsKnownEvent { get; } + + public TelemetryEventDefinition? Definition { get; } + + public IReadOnlyList MissingProperties { get; } + + public string EventName { get; } + + public static TelemetrySchemaValidationResult Valid(TelemetryEventDefinition definition) + { + return new TelemetrySchemaValidationResult(true, true, definition, Array.Empty(), definition.Name); + } + + public static TelemetrySchemaValidationResult UnknownEvent(string eventName) + { + return new TelemetrySchemaValidationResult(false, false, null, Array.Empty(), eventName); + } + + public static TelemetrySchemaValidationResult MissingRequiredProperties( + TelemetryEventDefinition definition, + IReadOnlyList missingProperties) + { + return new TelemetrySchemaValidationResult(false, true, definition, missingProperties, definition.Name); + } +} diff --git a/Pointframe/Services/Infrastructure/TelemetryHeartbeatService.cs b/Pointframe/Services/Infrastructure/TelemetryHeartbeatService.cs index 0d6e812..8f62f49 100644 --- a/Pointframe/Services/Infrastructure/TelemetryHeartbeatService.cs +++ b/Pointframe/Services/Infrastructure/TelemetryHeartbeatService.cs @@ -39,9 +39,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) { var uptimeMinutes = ((int)(DateTime.UtcNow - _startedAtUtc).TotalMinutes).ToString(); - _telemetry.TrackEvent("app_heartbeat", new Dictionary + _telemetry.TrackEvent(TelemetryEvents.AppHeartbeat, new Dictionary { - ["uptime_minutes"] = uptimeMinutes, + [TelemetryPropertyKeys.UptimeMinutes] = uptimeMinutes, }); } } diff --git a/Pointframe/Services/Infrastructure/TelemetryService.cs b/Pointframe/Services/Infrastructure/TelemetryService.cs index 28ee4d9..30b781e 100644 --- a/Pointframe/Services/Infrastructure/TelemetryService.cs +++ b/Pointframe/Services/Infrastructure/TelemetryService.cs @@ -10,6 +10,7 @@ internal sealed class TelemetryService : ITelemetryService, IDisposable private readonly IUserSettingsService _userSettings; private readonly string _appVersion; private readonly string _sessionId = Guid.NewGuid().ToString("N"); + private readonly string _telemetrySchemaVersion = "1"; private readonly object _syncRoot = new(); private volatile string? _lastEventName; private bool _disposed; @@ -55,53 +56,103 @@ internal TelemetryService( _logger = logger; } - public void TrackEvent(string name, IReadOnlyDictionary? properties = null) + public void TrackProductEvent(string name, IReadOnlyDictionary? properties = null) + { + TrackEventInternal(name, properties, TelemetryChannel.Product); + } + + public void TrackDiagnosticEvent(string name, IReadOnlyDictionary? properties = null) + { + TrackEventInternal(name, properties, TelemetryChannel.Diagnostic); + } + + public void TrackDiagnosticException( + Exception exception, + string? context = null, + IReadOnlyDictionary? properties = null) { if (_logger is null || _disposed) { return; } - _lastEventName = name; - var scope = BuildScope(properties); + var mergedProperties = new Dictionary + { + [TelemetryPropertyKeys.ExceptionType] = exception.GetType().Name, + }; + + if (context is not null) + { + mergedProperties[TelemetryPropertyKeys.Context] = context; + } + + var lastEvent = _lastEventName; + if (lastEvent is not null) + { + mergedProperties[TelemetryPropertyKeys.LastAction] = lastEvent; + } + + if (properties is not null) + { + foreach (var kvp in properties) + { + mergedProperties[kvp.Key] = kvp.Value; + } + } + + var validation = TelemetryEventCatalog.Validate(TelemetryEvents.UnhandledException, mergedProperties); + if (!validation.IsValid) + { + LogSchemaValidationFailure(validation); + } + + var scope = BuildScope(TelemetryChannel.Diagnostic, mergedProperties); using (_logger.BeginScope(scope)) { - _logger.LogInformation("{microsoft.custom_event.name}", name); + _logger.LogError("{microsoft.custom_event.name}", TelemetryEvents.UnhandledException); } } + public void TrackEvent(string name, IReadOnlyDictionary? properties = null) + { + TrackProductEvent(name, properties); + } + public void TrackException(Exception exception, string? context = null) + { + TrackDiagnosticException(exception, context); + } + + private void TrackEventInternal(string name, IReadOnlyDictionary? properties, TelemetryChannel defaultChannel) { if (_logger is null || _disposed) { return; } - var extra = new Dictionary { ["exception_type"] = exception.GetType().Name }; - if (context is not null) - { - extra["context"] = context; - } - - var lastEvent = _lastEventName; - if (lastEvent is not null) + _lastEventName = name; + var validation = TelemetryEventCatalog.Validate(name, properties); + var channel = validation.Definition?.Channel ?? defaultChannel; + if (!validation.IsValid) { - extra["last_action"] = lastEvent; + LogSchemaValidationFailure(validation); } - var scope = BuildScope(extra); + var scope = BuildScope(channel, properties); using (_logger.BeginScope(scope)) { - _logger.LogError("{microsoft.custom_event.name}", "unhandled_exception"); + _logger.LogInformation("{microsoft.custom_event.name}", name); } } - private Dictionary BuildScope(IReadOnlyDictionary? properties) + private Dictionary BuildScope(TelemetryChannel channel, IReadOnlyDictionary? properties) { var scope = new Dictionary { - ["version"] = _appVersion, + [TelemetryPropertyKeys.Version] = _appVersion, ["session_id"] = _sessionId, + ["telemetry_channel"] = channel.ToString().ToLowerInvariant(), + ["telemetry_schema_version"] = _telemetrySchemaVersion, }; var installId = _userSettings.Current.InstallId; @@ -121,6 +172,20 @@ public void TrackException(Exception exception, string? context = null) return scope; } + private void LogSchemaValidationFailure(TelemetrySchemaValidationResult validation) + { + if (validation.IsKnownEvent) + { + _logger?.LogWarning( + "Telemetry schema mismatch for {EventName}. Missing required properties: {MissingProperties}", + validation.EventName, + string.Join(",", validation.MissingProperties)); + return; + } + + _logger?.LogWarning("Telemetry event {EventName} is not registered in TelemetryEventCatalog", validation.EventName); + } + public void Flush() { Dispose(); diff --git a/Pointframe/Services/Infrastructure/TrayIconManager.cs b/Pointframe/Services/Infrastructure/TrayIconManager.cs index d99d29d..166ac65 100644 --- a/Pointframe/Services/Infrastructure/TrayIconManager.cs +++ b/Pointframe/Services/Infrastructure/TrayIconManager.cs @@ -457,7 +457,7 @@ private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) return; } - _telemetry.TrackEvent("update_check_manual"); + _telemetry.TrackEvent(TelemetryEvents.UpdateCheckManual); var result = await _updateService.CheckForUpdates(); if (!result.IsUpdateAvailable) @@ -525,7 +525,7 @@ private void TrimRecentRecording_Click(object sender, RoutedEventArgs e) return; } - _telemetry.TrackEvent("video_trim_opened"); + _telemetry.TrackEvent(TelemetryEvents.VideoTrimOpened); DismissTransientUi(); _ = _eventAggregator.Publish(new TrimRecordingRequestedMessage(recentRecording.OutputPath)); } @@ -545,7 +545,7 @@ private async void ExportRecentRecordingGif_Click(object sender, RoutedEventArgs var gifPath = Path.ChangeExtension(recentRecording.OutputPath, ".gif"); senderElement.IsEnabled = false; - _telemetry.TrackEvent("gif_export_started"); + _telemetry.TrackEvent(TelemetryEvents.GifExportStarted); var sw = Stopwatch.StartNew(); var success = true; @@ -562,16 +562,16 @@ private async void ExportRecentRecordingGif_Click(object sender, RoutedEventArgs { success = false; _logger.LogError(ex, "GIF export from recent recordings failed for {Path}", recentRecording.OutputPath); - _telemetry.TrackException(ex, "gif_export"); + _telemetry.TrackDiagnosticException(ex, "gif_export"); _messageBox.ShowWarning("The GIF export failed. Please try again.", "Export to GIF"); } finally { sw.Stop(); - _telemetry.TrackEvent("gif_export_completed", new Dictionary + _telemetry.TrackEvent(TelemetryEvents.GifExportCompleted, new Dictionary { - ["success"] = success ? "true" : "false", - ["duration_seconds"] = ((int)sw.Elapsed.TotalSeconds).ToString(), + [TelemetryPropertyKeys.Success] = success ? "true" : "false", + [TelemetryPropertyKeys.DurationSeconds] = ((int)sw.Elapsed.TotalSeconds).ToString(), }); senderElement.IsEnabled = true; } diff --git a/Pointframe/Services/Update/AutoUpdateService.cs b/Pointframe/Services/Update/AutoUpdateService.cs index 7694363..e45fe43 100644 --- a/Pointframe/Services/Update/AutoUpdateService.cs +++ b/Pointframe/Services/Update/AutoUpdateService.cs @@ -88,11 +88,17 @@ public async Task ConfirmAndInstall(UpdateCheckResult result) $"Version {v.Major}.{v.Minor}.{v.Build} is available. Download and install now?", "Update Available")) { - _telemetry.TrackEvent("update_dismissed", new Dictionary { ["version"] = $"{v.Major}.{v.Minor}.{v.Build}" }); + _telemetry.TrackEvent(TelemetryEvents.UpdateDismissed, new Dictionary + { + [TelemetryPropertyKeys.Version] = $"{v.Major}.{v.Minor}.{v.Build}", + }); return; } - _telemetry.TrackEvent("update_confirmed", new Dictionary { ["version"] = $"{v.Major}.{v.Minor}.{v.Build}" }); + _telemetry.TrackEvent(TelemetryEvents.UpdateConfirmed, new Dictionary + { + [TelemetryPropertyKeys.Version] = $"{v.Major}.{v.Minor}.{v.Build}", + }); var fileName = ResolveInstallerFileName(result.DownloadUrl, v); var destPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), fileName); var succeeded = await _downloadService.Show(result.DownloadUrl, destPath); diff --git a/Pointframe/ViewModels/AnnotationViewModel.cs b/Pointframe/ViewModels/AnnotationViewModel.cs index 0404ae8..68b5050 100644 --- a/Pointframe/ViewModels/AnnotationViewModel.cs +++ b/Pointframe/ViewModels/AnnotationViewModel.cs @@ -302,9 +302,9 @@ public void CommitGroup() _redoStack.Clear(); UndoCount = _undoStack.Count; RedoCount = 0; - _telemetry.TrackEvent("annotation_committed", new Dictionary + _telemetry.TrackEvent(TelemetryEvents.AnnotationCommitted, new Dictionary { - ["tool"] = SelectedTool.ToString(), + [TelemetryPropertyKeys.Tool] = SelectedTool.ToString(), }); } diff --git a/Pointframe/ViewModels/BeautifierViewModel.cs b/Pointframe/ViewModels/BeautifierViewModel.cs index 238096a..d77d749 100644 --- a/Pointframe/ViewModels/BeautifierViewModel.cs +++ b/Pointframe/ViewModels/BeautifierViewModel.cs @@ -92,7 +92,7 @@ private void Export() encoder.Frames.Add(BitmapFrame.Create(bitmap)); encoder.Save(outputStream); - _telemetry.TrackEvent("screenshot_beautified"); + _telemetry.TrackEvent(TelemetryEvents.ScreenshotBeautified); _logger.LogInformation("Beautified screenshot saved: {Path}", savePath); ToastRequested?.Invoke($"Saved \u2014 {System.IO.Path.GetFileName(savePath)}"); } @@ -102,7 +102,7 @@ private void CopyToClipboard() { var bitmap = RenderCurrent(); _clipboardService.SetImage(bitmap); - _telemetry.TrackEvent("screenshot_beautified_copied"); + _telemetry.TrackEvent(TelemetryEvents.ScreenshotBeautifiedCopied); ToastRequested?.Invoke("Copied to clipboard"); } diff --git a/Pointframe/ViewModels/LibraryViewModel.cs b/Pointframe/ViewModels/LibraryViewModel.cs index 28467fa..2bc15fb 100644 --- a/Pointframe/ViewModels/LibraryViewModel.cs +++ b/Pointframe/ViewModels/LibraryViewModel.cs @@ -140,7 +140,7 @@ private async Task Refresh() if (isOcrEligibleQuery) { - _telemetry.TrackEvent("library_ocr_search_used"); + _telemetry.TrackEvent(TelemetryEvents.LibraryOcrSearchUsed); } } catch (OperationCanceledException) @@ -279,6 +279,21 @@ private NullTelemetryService() { } + public void TrackProductEvent(string name, IReadOnlyDictionary? properties = null) + { + } + + public void TrackDiagnosticEvent(string name, IReadOnlyDictionary? properties = null) + { + } + + public void TrackDiagnosticException( + Exception exception, + string? context = null, + IReadOnlyDictionary? properties = null) + { + } + public void TrackEvent(string name, IReadOnlyDictionary? properties = null) { } diff --git a/Pointframe/ViewModels/OverlayViewModel.cs b/Pointframe/ViewModels/OverlayViewModel.cs index 1ffa6a6..d95830f 100644 --- a/Pointframe/ViewModels/OverlayViewModel.cs +++ b/Pointframe/ViewModels/OverlayViewModel.cs @@ -244,7 +244,7 @@ private void Pin() return; } - _telemetry.TrackEvent("capture_pinned"); + _telemetry.TrackEvent(TelemetryEvents.CapturePinned); PinRequested?.Invoke(bitmapCapture.ComposeBitmap(restoreOverlayVisibilityAfterCapture: false)); } @@ -258,7 +258,7 @@ private void Beautify() return; } - _telemetry.TrackEvent("beautify_opened"); + _telemetry.TrackEvent(TelemetryEvents.BeautifyOpened); BeautifyRequested?.Invoke(bitmapCapture.ComposeBitmap(restoreOverlayVisibilityAfterCapture: false)); } } diff --git a/Pointframe/ViewModels/TrimViewModel.cs b/Pointframe/ViewModels/TrimViewModel.cs index 192aed4..080ba08 100644 --- a/Pointframe/ViewModels/TrimViewModel.cs +++ b/Pointframe/ViewModels/TrimViewModel.cs @@ -92,7 +92,7 @@ private async Task SaveTrim() IsTrimming = true; StatusText = "Trimming…"; - _telemetry.TrackEvent("video_trim_started"); + _telemetry.TrackEvent(TelemetryEvents.VideoTrimStarted); var canceled = false; var success = true; @@ -122,17 +122,17 @@ await _trimService.Trim( { success = false; _logger.LogError(ex, "Trim failed for {Path}", InputPath); - _telemetry.TrackException(ex, "video_trim"); + _telemetry.TrackDiagnosticException(ex, "video_trim"); StatusText = "Trim failed. Please try again."; } finally { _trimCancellationSource = null; _closeWhenTrimCanceled = false; - _telemetry.TrackEvent("video_trim_completed", new Dictionary + _telemetry.TrackEvent(TelemetryEvents.VideoTrimCompleted, new Dictionary { - ["success"] = success ? "true" : "false", - ["canceled"] = canceled ? "true" : "false", + [TelemetryPropertyKeys.Success] = success ? "true" : "false", + [TelemetryPropertyKeys.Canceled] = canceled ? "true" : "false", }); IsTrimming = false; } diff --git a/Pointframe/Views/OverlayWindow.Recording.cs b/Pointframe/Views/OverlayWindow.Recording.cs index abc4a22..ce10ef4 100644 --- a/Pointframe/Views/OverlayWindow.Recording.cs +++ b/Pointframe/Views/OverlayWindow.Recording.cs @@ -1,5 +1,6 @@ using System.Windows; using System.Windows.Threading; +using Pointframe.Services; using Forms = System.Windows.Forms; namespace Pointframe; @@ -52,16 +53,19 @@ private async Task StartRecordingSession() catch (System.IO.FileNotFoundException ex) { Visibility = Visibility.Visible; - _telemetry.TrackEvent("ffmpeg_missing"); + _telemetry.TrackEvent(TelemetryEvents.FfmpegMissing); _messageBox.ShowWarning(ex.Message, "ffmpeg not found"); return; } - _telemetry.TrackEvent("recording_started", new System.Collections.Generic.Dictionary { ["type"] = "region" }); + _telemetry.TrackEvent(TelemetryEvents.RecordingStarted, new System.Collections.Generic.Dictionary + { + [TelemetryPropertyKeys.Type] = "region", + }); if (_userSettings.Current.RecordMicrophone && !_recorder.IsRecordingMicrophoneEnabled) { - _telemetry.TrackEvent("microphone_unavailable"); + _telemetry.TrackEvent(TelemetryEvents.MicrophoneUnavailable); _messageBox.ShowWarning( "Microphone recording is enabled, but no compatible microphone device was available. The recording will continue without microphone audio.", "Microphone unavailable"); From 37e01e4de717a389ad59bac360578726ae9faaa1 Mon Sep 17 00:00:00 2001 From: Dimitar Radenkov Date: Sat, 25 Jul 2026 08:14:48 +0300 Subject: [PATCH 2/2] Add telemetry tracking to Recording HUD and About dialogs --- Pointframe/AppServiceRegistration.cs | 3 +- .../Infrastructure/TelemetryEventCatalog.cs | 40 +++++++++++++ Pointframe/ViewModels/AboutViewModel.cs | 27 ++++++++- .../ViewModels/RecordingHudViewModel.cs | 56 +++++++++++++++++++ Pointframe/ViewModels/SettingsViewModel.cs | 43 +++++++++++++- 5 files changed, 165 insertions(+), 4 deletions(-) diff --git a/Pointframe/AppServiceRegistration.cs b/Pointframe/AppServiceRegistration.cs index a158065..486163c 100644 --- a/Pointframe/AppServiceRegistration.cs +++ b/Pointframe/AppServiceRegistration.cs @@ -73,7 +73,8 @@ internal static IServiceCollection AddPointframeAppServices(this IServiceCollect screenRecordingService, outputPath, sp.GetRequiredService(), - sp.GetRequiredService>())); + sp.GetRequiredService>(), + sp.GetRequiredService())); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Pointframe/Services/Infrastructure/TelemetryEventCatalog.cs b/Pointframe/Services/Infrastructure/TelemetryEventCatalog.cs index 2e68059..6674b21 100644 --- a/Pointframe/Services/Infrastructure/TelemetryEventCatalog.cs +++ b/Pointframe/Services/Infrastructure/TelemetryEventCatalog.cs @@ -10,10 +10,14 @@ public static class TelemetryPropertyKeys { public const string Action = "action"; public const string AppVersion = "version"; + public const string AnnotationInputState = "annotation_input_state"; + public const string AnnotationTool = "annotation_tool"; + public const string AppSection = "app_section"; public const string Canceled = "canceled"; public const string CaptureType = "capture_type"; public const string Context = "context"; public const string DelaySeconds = "delay_seconds"; + public const string DisplayMode = "display_mode"; public const string DurationMilliseconds = "duration_ms"; public const string DurationSeconds = "duration_seconds"; public const string ExceptionType = "exception_type"; @@ -25,11 +29,13 @@ public static class TelemetryPropertyKeys public const string SelectionWidthPx = "selection_width_px"; public const string SessionMinutes = "session_minutes"; public const string Source = "source"; + public const string State = "state"; public const string Success = "success"; public const string TimeFromInstallMinutes = "time_from_install_minutes"; public const string Tool = "tool"; public const string Type = "type"; public const string UptimeMinutes = "uptime_minutes"; + public const string UrlHost = "url_host"; public const string Version = "version"; public const string WithAudio = "with_audio"; } @@ -37,6 +43,9 @@ public static class TelemetryPropertyKeys public static class TelemetryEvents { public const string AnnotationCommitted = "annotation_committed"; + public const string AboutClosed = "about_closed"; + public const string AboutOpened = "about_opened"; + public const string AboutUrlOpened = "about_url_opened"; public const string AppClosed = "app_closed"; public const string AppHeartbeat = "app_heartbeat"; public const string AppStarted = "app_started"; @@ -56,8 +65,22 @@ public static class TelemetryEvents public const string OcrNoText = "ocr_no_text"; public const string OcrUsed = "ocr_used"; public const string OpenImageUsed = "open_image_used"; + public const string RecordingHudAnnotationInputToggled = "recording_hud_annotation_input_toggled"; + public const string RecordingHudClearAnnotations = "recording_hud_clear_annotations"; + public const string RecordingHudDisplayModeChanged = "recording_hud_display_mode_changed"; + public const string RecordingHudMicrophoneToggled = "recording_hud_microphone_toggled"; + public const string RecordingHudPauseToggled = "recording_hud_pause_toggled"; + public const string RecordingHudStopped = "recording_hud_stopped"; + public const string RecordingHudToolSelected = "recording_hud_tool_selected"; + public const string RecordingHudUndoAnnotations = "recording_hud_undo_annotations"; public const string RecordingCompleted = "recording_completed"; public const string RecordingStarted = "recording_started"; + public const string SettingsCanceled = "settings_canceled"; + public const string SettingsDefaultsRestored = "settings_defaults_restored"; + public const string SettingsOpened = "settings_opened"; + public const string SettingsSaved = "settings_saved"; + public const string SettingsSectionChanged = "settings_section_changed"; + public const string SettingsSectionReset = "settings_section_reset"; public const string ScreenshotBeautified = "screenshot_beautified"; public const string ScreenshotBeautifiedCopied = "screenshot_beautified_copied"; public const string SnipCancelled = "snip_cancelled"; @@ -98,6 +121,9 @@ public static class TelemetryEventCatalog new Dictionary(StringComparer.Ordinal) { [TelemetryEvents.AnnotationCommitted] = Product(TelemetryEvents.AnnotationCommitted, TelemetryPropertyKeys.Tool), + [TelemetryEvents.AboutClosed] = Product(TelemetryEvents.AboutClosed), + [TelemetryEvents.AboutOpened] = Product(TelemetryEvents.AboutOpened), + [TelemetryEvents.AboutUrlOpened] = Product(TelemetryEvents.AboutUrlOpened, TelemetryPropertyKeys.UrlHost), [TelemetryEvents.AppClosed] = Product(TelemetryEvents.AppClosed, TelemetryPropertyKeys.SessionMinutes), [TelemetryEvents.AppHeartbeat] = Diagnostic(TelemetryEvents.AppHeartbeat, TelemetryPropertyKeys.UptimeMinutes), [TelemetryEvents.AppStarted] = Product(TelemetryEvents.AppStarted, TelemetryPropertyKeys.OsBuild, TelemetryPropertyKeys.ScreenCount), @@ -117,8 +143,22 @@ public static class TelemetryEventCatalog [TelemetryEvents.OcrNoText] = Product(TelemetryEvents.OcrNoText, TelemetryPropertyKeys.SelectionWidthPx, TelemetryPropertyKeys.SelectionHeightPx), [TelemetryEvents.OcrUsed] = Product(TelemetryEvents.OcrUsed, TelemetryPropertyKeys.SelectionWidthPx, TelemetryPropertyKeys.SelectionHeightPx), [TelemetryEvents.OpenImageUsed] = Product(TelemetryEvents.OpenImageUsed), + [TelemetryEvents.RecordingHudAnnotationInputToggled] = Product(TelemetryEvents.RecordingHudAnnotationInputToggled, TelemetryPropertyKeys.AnnotationInputState), + [TelemetryEvents.RecordingHudClearAnnotations] = Product(TelemetryEvents.RecordingHudClearAnnotations), + [TelemetryEvents.RecordingHudDisplayModeChanged] = Product(TelemetryEvents.RecordingHudDisplayModeChanged, TelemetryPropertyKeys.DisplayMode), + [TelemetryEvents.RecordingHudMicrophoneToggled] = Product(TelemetryEvents.RecordingHudMicrophoneToggled, TelemetryPropertyKeys.State), + [TelemetryEvents.RecordingHudPauseToggled] = Product(TelemetryEvents.RecordingHudPauseToggled, TelemetryPropertyKeys.State), + [TelemetryEvents.RecordingHudStopped] = Product(TelemetryEvents.RecordingHudStopped, TelemetryPropertyKeys.DurationSeconds), + [TelemetryEvents.RecordingHudToolSelected] = Product(TelemetryEvents.RecordingHudToolSelected, TelemetryPropertyKeys.AnnotationTool), + [TelemetryEvents.RecordingHudUndoAnnotations] = Product(TelemetryEvents.RecordingHudUndoAnnotations), [TelemetryEvents.RecordingCompleted] = Product(TelemetryEvents.RecordingCompleted), [TelemetryEvents.RecordingStarted] = Product(TelemetryEvents.RecordingStarted, TelemetryPropertyKeys.Type), + [TelemetryEvents.SettingsCanceled] = Product(TelemetryEvents.SettingsCanceled), + [TelemetryEvents.SettingsDefaultsRestored] = Product(TelemetryEvents.SettingsDefaultsRestored), + [TelemetryEvents.SettingsOpened] = Product(TelemetryEvents.SettingsOpened, TelemetryPropertyKeys.AppSection), + [TelemetryEvents.SettingsSaved] = Product(TelemetryEvents.SettingsSaved, TelemetryPropertyKeys.AppSection), + [TelemetryEvents.SettingsSectionChanged] = Product(TelemetryEvents.SettingsSectionChanged, TelemetryPropertyKeys.AppSection), + [TelemetryEvents.SettingsSectionReset] = Product(TelemetryEvents.SettingsSectionReset, TelemetryPropertyKeys.AppSection), [TelemetryEvents.ScreenshotBeautified] = Product(TelemetryEvents.ScreenshotBeautified), [TelemetryEvents.ScreenshotBeautifiedCopied] = Product(TelemetryEvents.ScreenshotBeautifiedCopied), [TelemetryEvents.SnipCancelled] = Product(TelemetryEvents.SnipCancelled, TelemetryPropertyKeys.Type), diff --git a/Pointframe/ViewModels/AboutViewModel.cs b/Pointframe/ViewModels/AboutViewModel.cs index 9bea760..d84f9e1 100644 --- a/Pointframe/ViewModels/AboutViewModel.cs +++ b/Pointframe/ViewModels/AboutViewModel.cs @@ -6,21 +6,44 @@ namespace Pointframe.ViewModels; public partial class AboutViewModel : ObservableObject { private readonly IProcessService _process; + private readonly ITelemetryService _telemetry; public string Version { get; } public event Action? RequestClose; public AboutViewModel(IAppVersionService appVersion, IProcessService process) + : this(appVersion, process, NullTelemetryService.Instance) + { + } + + public AboutViewModel(IAppVersionService appVersion, IProcessService process, ITelemetryService telemetry) { _process = process; + _telemetry = telemetry; var v = appVersion.Current; Version = $"Version {v.Major}.{v.Minor}.{v.Build}"; + _telemetry.TrackEvent(TelemetryEvents.AboutOpened); } [RelayCommand] - private void OpenUrl(string url) => + private void OpenUrl(string url) + { + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) + && !string.IsNullOrWhiteSpace(uri.Host)) + { + _telemetry.TrackEvent(TelemetryEvents.AboutUrlOpened, new Dictionary + { + [TelemetryPropertyKeys.UrlHost] = uri.Host, + }); + } + _process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } [RelayCommand] - private void Close() => RequestClose?.Invoke(); + private void Close() + { + _telemetry.TrackEvent(TelemetryEvents.AboutClosed); + RequestClose?.Invoke(); + } } diff --git a/Pointframe/ViewModels/RecordingHudViewModel.cs b/Pointframe/ViewModels/RecordingHudViewModel.cs index d6e3fa5..5dd883d 100644 --- a/Pointframe/ViewModels/RecordingHudViewModel.cs +++ b/Pointframe/ViewModels/RecordingHudViewModel.cs @@ -8,6 +8,7 @@ public partial class RecordingHudViewModel : ObservableObject private readonly IScreenRecordingService _svc; private readonly IEventAggregator _eventAggregator; private readonly ILogger _logger; + private readonly ITelemetryService _telemetry; private RecordingAnnotationViewModel? _annotationViewModel; private Func? _toggleAnnotationInput; private CancellationTokenSource? _elapsedCts; @@ -75,11 +76,22 @@ public RecordingHudViewModel( string outputPath, IEventAggregator eventAggregator, ILogger logger) + : this(svc, outputPath, eventAggregator, logger, NullTelemetryService.Instance) + { + } + + public RecordingHudViewModel( + IScreenRecordingService svc, + string outputPath, + IEventAggregator eventAggregator, + ILogger logger, + ITelemetryService telemetry) { _svc = svc; OutputPath = outputPath; _eventAggregator = eventAggregator; _logger = logger; + _telemetry = telemetry; CanToggleMicrophone = svc.CanToggleMicrophone; IsMicrophoneMuted = svc.IsMicrophoneMuted; } @@ -137,6 +149,10 @@ private async Task Stop() CancelElapsedTimer(); await Task.Run(() => _svc.Stop()).ConfigureAwait(true); _logger.LogInformation("Recording saved to {Path}", OutputPath); + _telemetry.TrackEvent(TelemetryEvents.RecordingHudStopped, new Dictionary + { + [TelemetryPropertyKeys.DurationSeconds] = GetElapsedSeconds().ToString(), + }); CloseRequested?.Invoke(); await _eventAggregator.Publish(new RecordingCompletedMessage(OutputPath, ElapsedText)).ConfigureAwait(true); @@ -150,6 +166,10 @@ private void PauseResume() _totalPausedDuration += DateTime.UtcNow - _pausedAt; _svc.Resume(); PauseResumeLabel = "⏸ Pause"; + _telemetry.TrackEvent(TelemetryEvents.RecordingHudPauseToggled, new Dictionary + { + [TelemetryPropertyKeys.State] = "resumed", + }); _logger.LogInformation("Recording resumed from HUD"); } else @@ -157,6 +177,10 @@ private void PauseResume() _pausedAt = DateTime.UtcNow; _svc.Pause(); PauseResumeLabel = "▶ Resume"; + _telemetry.TrackEvent(TelemetryEvents.RecordingHudPauseToggled, new Dictionary + { + [TelemetryPropertyKeys.State] = "paused", + }); _logger.LogInformation("Recording paused from HUD"); } } @@ -177,6 +201,10 @@ private void ToggleMicrophone() } IsMicrophoneMuted = nextMutedState; + _telemetry.TrackEvent(TelemetryEvents.RecordingHudMicrophoneToggled, new Dictionary + { + [TelemetryPropertyKeys.State] = nextMutedState ? "muted" : "unmuted", + }); _logger.LogInformation("Recording microphone toggled from HUD: {State}", nextMutedState ? "muted" : "unmuted"); } @@ -184,12 +212,20 @@ private void ToggleMicrophone() private void ExpandHud() { IsCompactMode = false; + _telemetry.TrackEvent(TelemetryEvents.RecordingHudDisplayModeChanged, new Dictionary + { + [TelemetryPropertyKeys.DisplayMode] = "expanded", + }); } [RelayCommand] private void MinimizeHud() { IsCompactMode = true; + _telemetry.TrackEvent(TelemetryEvents.RecordingHudDisplayModeChanged, new Dictionary + { + [TelemetryPropertyKeys.DisplayMode] = "compact", + }); } [RelayCommand] @@ -203,6 +239,10 @@ private void ToggleAnnotationInput() var isInputArmed = _toggleAnnotationInput(); IsAnnotationInputArmed = isInputArmed; AnnotationModeLabel = isInputArmed ? "Interact" : "Annotate"; + _telemetry.TrackEvent(TelemetryEvents.RecordingHudAnnotationInputToggled, new Dictionary + { + [TelemetryPropertyKeys.AnnotationInputState] = isInputArmed ? "armed" : "disarmed", + }); _logger.LogInformation("Recording annotation spike toggled: {IsInputArmed}", isInputArmed); } @@ -217,6 +257,10 @@ private void SelectTool(string? toolName) } _annotationViewModel.SelectedTool = selectedTool; + _telemetry.TrackEvent(TelemetryEvents.RecordingHudToolSelected, new Dictionary + { + [TelemetryPropertyKeys.AnnotationTool] = selectedTool.ToString().ToLowerInvariant(), + }); _logger.LogInformation("Recording annotation tool selected from HUD: {Tool}", selectedTool); } @@ -229,6 +273,7 @@ private void UndoAnnotations() } _annotationViewModel.UndoCommand.Execute(null); + _telemetry.TrackEvent(TelemetryEvents.RecordingHudUndoAnnotations); } [RelayCommand] @@ -240,5 +285,16 @@ private void ClearAnnotations() } _annotationViewModel.ClearCommand.Execute(null); + _telemetry.TrackEvent(TelemetryEvents.RecordingHudClearAnnotations); + } + + private int GetElapsedSeconds() + { + if (TimeSpan.TryParseExact(ElapsedText, @"mm\:ss", null, out var elapsed)) + { + return (int)elapsed.TotalSeconds; + } + + return 0; } } diff --git a/Pointframe/ViewModels/SettingsViewModel.cs b/Pointframe/ViewModels/SettingsViewModel.cs index a2b24df..f25a579 100644 --- a/Pointframe/ViewModels/SettingsViewModel.cs +++ b/Pointframe/ViewModels/SettingsViewModel.cs @@ -58,6 +58,7 @@ private sealed record OverlayShortcutDescriptor( private readonly IDialogService _dialogService; private readonly IMicrophoneDeviceService _microphoneDeviceService; private readonly IUserSettingsService _settingsService; + private readonly ITelemetryService _telemetry; private readonly IThemeService _themeService; private readonly AppTheme _originalTheme; private readonly IReadOnlyList _availableMicrophoneDevices; @@ -66,11 +67,26 @@ private sealed record OverlayShortcutDescriptor( private DateTime? _lastAutoUpdateCheckUtc; private readonly ScreenshotWatermarkSettings _watermarkOther; - public SettingsViewModel(IUserSettingsService settingsService, IThemeService themeService, IDialogService dialogService, IMicrophoneDeviceService microphoneDeviceService) + public SettingsViewModel( + IUserSettingsService settingsService, + IThemeService themeService, + IDialogService dialogService, + IMicrophoneDeviceService microphoneDeviceService) + : this(settingsService, themeService, dialogService, microphoneDeviceService, NullTelemetryService.Instance) + { + } + + public SettingsViewModel( + IUserSettingsService settingsService, + IThemeService themeService, + IDialogService dialogService, + IMicrophoneDeviceService microphoneDeviceService, + ITelemetryService telemetry) { _dialogService = dialogService; _microphoneDeviceService = microphoneDeviceService; _settingsService = settingsService; + _telemetry = telemetry; _themeService = themeService; _availableMicrophoneDevices = microphoneDeviceService.GetAvailableCaptureDeviceNames(); @@ -128,6 +144,11 @@ public SettingsViewModel(IUserSettingsService settingsService, IThemeService the OnPropertyChanged(nameof(CanAddPreset)); AddPresetCommand.NotifyCanExecuteChanged(); }; + + _telemetry.TrackEvent(TelemetryEvents.SettingsOpened, new Dictionary + { + [TelemetryPropertyKeys.AppSection] = SelectedSection.ToString().ToLowerInvariant(), + }); } public IReadOnlyList Sections => SectionItems; @@ -330,6 +351,14 @@ partial void OnDefaultAnnotationColorChanged(Color value) => partial void OnAppThemeChanged(AppTheme value) => _themeService.Apply(value); + partial void OnSelectedSectionChanged(SettingsSection value) + { + _telemetry.TrackEvent(TelemetryEvents.SettingsSectionChanged, new Dictionary + { + [TelemetryPropertyKeys.AppSection] = value.ToString().ToLowerInvariant(), + }); + } + public SolidColorBrush ColorPreviewBrush => new(DefaultAnnotationColor); public double AnnotationPreviewThickness => Math.Max(DefaultStrokeThickness, 1d); @@ -473,6 +502,10 @@ private void Save() FirstCaptureCompletedTracked = currentSettings.FirstCaptureCompletedTracked, FirstRecordingCompletedTracked = currentSettings.FirstRecordingCompletedTracked, }); + _telemetry.TrackEvent(TelemetryEvents.SettingsSaved, new Dictionary + { + [TelemetryPropertyKeys.AppSection] = SelectedSection.ToString().ToLowerInvariant(), + }); RequestClose?.Invoke(); } @@ -594,6 +627,11 @@ internal void ApplyOverlayShortcutCapture(uint vk, HotkeyModifiers modifiers) [RelayCommand] private void ResetCurrentSection() { + _telemetry.TrackEvent(TelemetryEvents.SettingsSectionReset, new Dictionary + { + [TelemetryPropertyKeys.AppSection] = SelectedSection.ToString().ToLowerInvariant(), + }); + var defaults = new UserSettings(); switch (SelectedSection) { @@ -648,6 +686,8 @@ private void ResetCurrentSection() [RelayCommand] private void RestoreDefaults() { + _telemetry.TrackEvent(TelemetryEvents.SettingsDefaultsRestored); + var defaults = new UserSettings(); _recordingFps = defaults.RecordingFps; _hudGapPixels = defaults.HudGapPixels; @@ -692,6 +732,7 @@ private void RestoreDefaults() [RelayCommand] private void Cancel() { + _telemetry.TrackEvent(TelemetryEvents.SettingsCanceled); _themeService.Apply(_originalTheme); RequestClose?.Invoke(); }