diff --git a/Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs b/Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs new file mode 100644 index 0000000..c0f0564 --- /dev/null +++ b/Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs @@ -0,0 +1,82 @@ +using Moq; +using Pointframe.Models; +using Pointframe.Services; +using Xunit; + +namespace Pointframe.Tests.Services; + +public sealed class ActivationTelemetryServiceTests +{ + [Fact] + public void TrackCaptureCompleted_TracksFirstCaptureOnlyOnce() + { + var events = new List<(string Name, IReadOnlyDictionary? Props)>(); + var telemetryMock = new Mock(); + telemetryMock + .Setup(service => service.TrackEvent(It.IsAny(), It.IsAny?>())) + .Callback?>((name, props) => events.Add((name, props))); + + var settings = new UserSettings + { + InstallCreatedUtc = DateTime.UtcNow.AddMinutes(-20), + RecordMicrophone = true, + }; + + var settingsMock = new Mock(); + settingsMock.SetupGet(service => service.Current).Returns(() => settings); + settingsMock + .Setup(service => service.Update(It.IsAny>())) + .Callback>(mutate => mutate(settings)); + + var sut = new ActivationTelemetryService(telemetryMock.Object, settingsMock.Object); + + sut.TrackCaptureCompleted(); + sut.TrackCaptureCompleted(); + + 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")); + + var firstCapture = events.Single(item => item.Name == "first_capture_completed"); + Assert.NotNull(firstCapture.Props); + Assert.Equal("screenshot", firstCapture.Props!["capture_type"]); + Assert.True(firstCapture.Props.ContainsKey("time_from_install_minutes")); + } + + [Fact] + public void TrackRecordingCompleted_TracksFirstRecordingOnlyOnceAndIncludesDuration() + { + var events = new List<(string Name, IReadOnlyDictionary? Props)>(); + var telemetryMock = new Mock(); + telemetryMock + .Setup(service => service.TrackEvent(It.IsAny(), It.IsAny?>())) + .Callback?>((name, props) => events.Add((name, props))); + + var settings = new UserSettings + { + InstallCreatedUtc = DateTime.UtcNow.AddMinutes(-45), + RecordMicrophone = true, + }; + + var settingsMock = new Mock(); + settingsMock.SetupGet(service => service.Current).Returns(() => settings); + settingsMock + .Setup(service => service.Update(It.IsAny>())) + .Callback>(mutate => mutate(settings)); + + var sut = new ActivationTelemetryService(telemetryMock.Object, settingsMock.Object); + + sut.TrackRecordingCompleted("01:05"); + 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")); + + var firstRecording = events.Single(item => item.Name == "first_recording_completed"); + 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")); + } +} diff --git a/Pointframe.Tests/ViewModels/SettingsViewModelTests.cs b/Pointframe.Tests/ViewModels/SettingsViewModelTests.cs index 3c199f3..3938c05 100644 --- a/Pointframe.Tests/ViewModels/SettingsViewModelTests.cs +++ b/Pointframe.Tests/ViewModels/SettingsViewModelTests.cs @@ -487,6 +487,33 @@ public void RestoreDefaultsCommand_ThenEditingVisibleSetting_StillPersistsHidden Assert.Equal(defaults.LastAutoUpdateCheckUtc, saved.LastAutoUpdateCheckUtc); } + [Fact] + public void Save_PreservesActivationTelemetryFields() + { + var installCreatedUtc = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc); + var current = new UserSettings + { + InstallId = "install-123", + InstallCreatedUtc = installCreatedUtc, + FirstCaptureCompletedTracked = true, + FirstRecordingCompletedTracked = true, + }; + + var mock = new Mock(); + mock.SetupGet(s => s.Current).Returns(current); + UserSettings? saved = null; + mock.Setup(s => s.Save(It.IsAny())).Callback(s => saved = s); + var vm = new SettingsViewModel(mock.Object, Mock.Of(), Mock.Of(), CreateMicrophoneDeviceService()); + + vm.SaveCommand.Execute(null); + + Assert.NotNull(saved); + Assert.Equal(current.InstallId, saved!.InstallId); + Assert.Equal(current.InstallCreatedUtc, saved.InstallCreatedUtc); + Assert.Equal(current.FirstCaptureCompletedTracked, saved.FirstCaptureCompletedTracked); + Assert.Equal(current.FirstRecordingCompletedTracked, saved.FirstRecordingCompletedTracked); + } + [Fact] public void Sections_ProvideSingleSourceOfTruthForHeaderMetadata() { diff --git a/Pointframe/App.xaml.cs b/Pointframe/App.xaml.cs index ef26b92..bb3778e 100644 --- a/Pointframe/App.xaml.cs +++ b/Pointframe/App.xaml.cs @@ -30,6 +30,7 @@ public partial class App : Application private IAppErrorHandler _errorHandler = null!; private ITrayIconManager _trayIconManager = null!; private ICaptureLaunchService _captureLaunch = null!; + private IActivationTelemetryService _activationTelemetry = null!; private IEventSubscription? _updateAvailableSubscription; private IEventSubscription? _recordingCompletedSubscription; private IEventSubscription? _captureCompletedSubscription; @@ -88,6 +89,7 @@ protected override void OnStartup(StartupEventArgs e) _errorHandler = _host.Services.GetRequiredService(); _captureLaunch = _host.Services.GetRequiredService(); _telemetry = _host.Services.GetRequiredService(); + _activationTelemetry = _host.Services.GetRequiredService(); _themeService.Apply(_userSettings.Current.Theme); if (!automationLaunchOptions.IsAutomationMode) { @@ -158,6 +160,7 @@ protected override void OnStartup(StartupEventArgs e) private static void ConfigureServices(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -423,23 +426,14 @@ private ValueTask HandleUpdateAvailable(UpdateAvailableMessage message) private ValueTask HandleRecordingCompleted(RecordingCompletedMessage message) { _trayIconManager.HandleRecordingCompleted(message.OutputPath, message.ElapsedText); - Dictionary? props = null; - if (TimeSpan.TryParseExact(message.ElapsedText, @"mm\:ss", null, out var duration)) - { - props = new Dictionary { ["duration_seconds"] = ((int)duration.TotalSeconds).ToString() }; - } - - _telemetry.TrackEvent("recording_completed", props); + _activationTelemetry.TrackRecordingCompleted(message.ElapsedText); return ValueTask.CompletedTask; } private ValueTask HandleCaptureCompleted(CaptureCompletedMessage message) { _trayIconManager.HandleCaptureCompleted(message.OutputPath); - _telemetry.TrackEvent("capture_completed", new Dictionary - { - ["action"] = "copy", - }); + _activationTelemetry.TrackCaptureCompleted(); return ValueTask.CompletedTask; } @@ -449,13 +443,20 @@ private void EnsureInstallId() { try { - _userSettings.Update(s => s.InstallId = Guid.NewGuid().ToString("N")); + _userSettings.Update(s => + { + s.InstallId = Guid.NewGuid().ToString("N"); + s.InstallCreatedUtc = DateTime.UtcNow; + }); } catch (Exception ex) { Log.Warning(ex, "Failed to persist install ID; telemetry will use an in-memory ID for this session."); _userSettings.Current.InstallId = Guid.NewGuid().ToString("N"); + _userSettings.Current.InstallCreatedUtc = DateTime.UtcNow; } + + return; } } } diff --git a/Pointframe/Models/UserSettings.cs b/Pointframe/Models/UserSettings.cs index 12f3185..2507518 100644 --- a/Pointframe/Models/UserSettings.cs +++ b/Pointframe/Models/UserSettings.cs @@ -52,4 +52,10 @@ public sealed class UserSettings /// Used for telemetry to count unique installs without tracking identity. /// public string? InstallId { get; set; } + + public DateTime? InstallCreatedUtc { get; set; } + + public bool FirstCaptureCompletedTracked { get; set; } + + public bool FirstRecordingCompletedTracked { get; set; } } diff --git a/Pointframe/Services/Infrastructure/ActivationTelemetryService.cs b/Pointframe/Services/Infrastructure/ActivationTelemetryService.cs new file mode 100644 index 0000000..573bf04 --- /dev/null +++ b/Pointframe/Services/Infrastructure/ActivationTelemetryService.cs @@ -0,0 +1,143 @@ +namespace Pointframe.Services; + +public sealed class ActivationTelemetryService : IActivationTelemetryService +{ + private readonly ITelemetryService _telemetry; + private readonly IUserSettingsService _userSettings; + + public ActivationTelemetryService( + ITelemetryService telemetry, + IUserSettingsService userSettings) + { + _telemetry = telemetry; + _userSettings = userSettings; + } + + public void TrackCaptureCompleted() + { + _telemetry.TrackEvent("capture_completed", new Dictionary + { + ["action"] = "copy", + }); + + var shouldTrackFirstCapture = false; + int? timeFromInstallMinutes = null; + + _userSettings.Update(settings => + { + if (settings.FirstCaptureCompletedTracked) + { + return; + } + + settings.FirstCaptureCompletedTracked = true; + shouldTrackFirstCapture = true; + timeFromInstallMinutes = GetTimeFromInstallMinutes(settings.InstallCreatedUtc); + }); + + if (!shouldTrackFirstCapture) + { + return; + } + + var props = new Dictionary + { + ["capture_type"] = "screenshot", + }; + + if (timeFromInstallMinutes is not null) + { + props["time_from_install_minutes"] = timeFromInstallMinutes.Value.ToString(); + } + + _telemetry.TrackEvent("first_capture_completed", props); + } + + public void TrackRecordingCompleted(string elapsedText) + { + var durationSeconds = TryGetDurationSeconds(elapsedText); + Dictionary? recordingProps = null; + + if (durationSeconds is not null) + { + recordingProps = new Dictionary + { + ["duration_seconds"] = durationSeconds.Value.ToString(), + }; + } + + _telemetry.TrackEvent("recording_completed", recordingProps); + + var shouldTrackFirstRecording = false; + int? timeFromInstallMinutes = null; + var withAudio = false; + + _userSettings.Update(settings => + { + if (settings.FirstRecordingCompletedTracked) + { + return; + } + + settings.FirstRecordingCompletedTracked = true; + shouldTrackFirstRecording = true; + timeFromInstallMinutes = GetTimeFromInstallMinutes(settings.InstallCreatedUtc); + withAudio = settings.RecordMicrophone; + }); + + if (!shouldTrackFirstRecording) + { + return; + } + + var firstRecordingProps = new Dictionary + { + ["with_audio"] = withAudio ? "true" : "false", + }; + + if (durationSeconds is not null) + { + firstRecordingProps["duration_seconds"] = durationSeconds.Value.ToString(); + } + + if (timeFromInstallMinutes is not null) + { + firstRecordingProps["time_from_install_minutes"] = timeFromInstallMinutes.Value.ToString(); + } + + _telemetry.TrackEvent("first_recording_completed", firstRecordingProps); + } + + private static int? TryGetDurationSeconds(string elapsedText) + { + if (TimeSpan.TryParseExact(elapsedText, @"mm\:ss", null, out var duration)) + { + return (int)duration.TotalSeconds; + } + + return null; + } + + private static int? GetTimeFromInstallMinutes(DateTime? installCreatedUtc) + { + if (installCreatedUtc is null) + { + return null; + } + + var utcInstallTime = installCreatedUtc.Value.Kind switch + { + DateTimeKind.Utc => installCreatedUtc.Value, + DateTimeKind.Local => installCreatedUtc.Value.ToUniversalTime(), + _ => DateTime.SpecifyKind(installCreatedUtc.Value, DateTimeKind.Utc), + }; + + var elapsed = DateTime.UtcNow - utcInstallTime; + if (elapsed < TimeSpan.Zero) + { + return null; + } + + return (int)elapsed.TotalMinutes; + } +} diff --git a/Pointframe/Services/Infrastructure/IActivationTelemetryService.cs b/Pointframe/Services/Infrastructure/IActivationTelemetryService.cs new file mode 100644 index 0000000..c6bfa79 --- /dev/null +++ b/Pointframe/Services/Infrastructure/IActivationTelemetryService.cs @@ -0,0 +1,8 @@ +namespace Pointframe.Services; + +public interface IActivationTelemetryService +{ + void TrackCaptureCompleted(); + + void TrackRecordingCompleted(string elapsedText); +} diff --git a/Pointframe/Services/Infrastructure/UserSettingsService.cs b/Pointframe/Services/Infrastructure/UserSettingsService.cs index 31143f0..55ac578 100644 --- a/Pointframe/Services/Infrastructure/UserSettingsService.cs +++ b/Pointframe/Services/Infrastructure/UserSettingsService.cs @@ -143,5 +143,8 @@ private static UserSettings Clone(UserSettings settings) => StrokeThickness = p.StrokeThickness, })], InstallId = settings.InstallId, + InstallCreatedUtc = settings.InstallCreatedUtc, + FirstCaptureCompletedTracked = settings.FirstCaptureCompletedTracked, + FirstRecordingCompletedTracked = settings.FirstRecordingCompletedTracked, }; } diff --git a/Pointframe/ViewModels/SettingsViewModel.cs b/Pointframe/ViewModels/SettingsViewModel.cs index c63e9ce..c2691f3 100644 --- a/Pointframe/ViewModels/SettingsViewModel.cs +++ b/Pointframe/ViewModels/SettingsViewModel.cs @@ -237,6 +237,7 @@ private void Save() { var c = DefaultAnnotationColor; var clampedRecordingCursorHighlightSize = ClampRecordingCursorHighlightSize(RecordingCursorHighlightSize); + var currentSettings = _settingsService.Current; RecordingCursorHighlightSize = clampedRecordingCursorHighlightSize; _settingsService.Save(new UserSettings @@ -263,7 +264,10 @@ private void Save() AutoUpdateCheckInterval = AutoUpdateCheckInterval, LastAutoUpdateCheckUtc = _lastAutoUpdateCheckUtc, Theme = AppTheme, - InstallId = _settingsService.Current.InstallId, + InstallId = currentSettings.InstallId, + InstallCreatedUtc = currentSettings.InstallCreatedUtc, + FirstCaptureCompletedTracked = currentSettings.FirstCaptureCompletedTracked, + FirstRecordingCompletedTracked = currentSettings.FirstRecordingCompletedTracked, }); RequestClose?.Invoke(); }