Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>? Props)>();
var telemetryMock = new Mock<ITelemetryService>();
telemetryMock
.Setup(service => service.TrackEvent(It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, string>?>()))
.Callback<string, IReadOnlyDictionary<string, string>?>((name, props) => events.Add((name, props)));

var settings = new UserSettings
{
InstallCreatedUtc = DateTime.UtcNow.AddMinutes(-20),
RecordMicrophone = true,
};

var settingsMock = new Mock<IUserSettingsService>();
settingsMock.SetupGet(service => service.Current).Returns(() => settings);
settingsMock
.Setup(service => service.Update(It.IsAny<Action<UserSettings>>()))
.Callback<Action<UserSettings>>(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<string, string>? Props)>();
var telemetryMock = new Mock<ITelemetryService>();
telemetryMock
.Setup(service => service.TrackEvent(It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, string>?>()))
.Callback<string, IReadOnlyDictionary<string, string>?>((name, props) => events.Add((name, props)));

var settings = new UserSettings
{
InstallCreatedUtc = DateTime.UtcNow.AddMinutes(-45),
RecordMicrophone = true,
};

var settingsMock = new Mock<IUserSettingsService>();
settingsMock.SetupGet(service => service.Current).Returns(() => settings);
settingsMock
.Setup(service => service.Update(It.IsAny<Action<UserSettings>>()))
.Callback<Action<UserSettings>>(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"));
}
}
27 changes: 27 additions & 0 deletions Pointframe.Tests/ViewModels/SettingsViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IUserSettingsService>();
mock.SetupGet(s => s.Current).Returns(current);
UserSettings? saved = null;
mock.Setup(s => s.Save(It.IsAny<UserSettings>())).Callback<UserSettings>(s => saved = s);
var vm = new SettingsViewModel(mock.Object, Mock.Of<IThemeService>(), Mock.Of<IDialogService>(), 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()
{
Expand Down
25 changes: 13 additions & 12 deletions Pointframe/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,6 +89,7 @@ protected override void OnStartup(StartupEventArgs e)
_errorHandler = _host.Services.GetRequiredService<IAppErrorHandler>();
_captureLaunch = _host.Services.GetRequiredService<ICaptureLaunchService>();
_telemetry = _host.Services.GetRequiredService<ITelemetryService>();
_activationTelemetry = _host.Services.GetRequiredService<IActivationTelemetryService>();
_themeService.Apply(_userSettings.Current.Theme);
if (!automationLaunchOptions.IsAutomationMode)
{
Expand Down Expand Up @@ -158,6 +160,7 @@ protected override void OnStartup(StartupEventArgs e)
private static void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<ITelemetryService, TelemetryService>();
services.AddSingleton<IActivationTelemetryService, ActivationTelemetryService>();
services.AddSingleton<IThemeService, ThemeService>();
services.AddSingleton<IAppVersionService, AppVersionService>();
services.AddSingleton<IClipboardService, ClipboardService>();
Expand Down Expand Up @@ -423,23 +426,14 @@ private ValueTask HandleUpdateAvailable(UpdateAvailableMessage message)
private ValueTask HandleRecordingCompleted(RecordingCompletedMessage message)
{
_trayIconManager.HandleRecordingCompleted(message.OutputPath, message.ElapsedText);
Dictionary<string, string>? props = null;
if (TimeSpan.TryParseExact(message.ElapsedText, @"mm\:ss", null, out var duration))
{
props = new Dictionary<string, string> { ["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<string, string>
{
["action"] = "copy",
});
_activationTelemetry.TrackCaptureCompleted();
return ValueTask.CompletedTask;
}

Expand All @@ -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;
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions Pointframe/Models/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,10 @@ public sealed class UserSettings
/// Used for telemetry to count unique installs without tracking identity.
/// </summary>
public string? InstallId { get; set; }

public DateTime? InstallCreatedUtc { get; set; }

public bool FirstCaptureCompletedTracked { get; set; }

public bool FirstRecordingCompletedTracked { get; set; }
}
143 changes: 143 additions & 0 deletions Pointframe/Services/Infrastructure/ActivationTelemetryService.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>
{
["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<string, string>
{
["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<string, string>? recordingProps = null;

if (durationSeconds is not null)
{
recordingProps = new Dictionary<string, string>
{
["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<string, string>
{
["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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Pointframe.Services;

public interface IActivationTelemetryService
{
void TrackCaptureCompleted();

void TrackRecordingCompleted(string elapsedText);
}
3 changes: 3 additions & 0 deletions Pointframe/Services/Infrastructure/UserSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
6 changes: 5 additions & 1 deletion Pointframe/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ private void Save()
{
var c = DefaultAnnotationColor;
var clampedRecordingCursorHighlightSize = ClampRecordingCursorHighlightSize(RecordingCursorHighlightSize);
var currentSettings = _settingsService.Current;
RecordingCursorHighlightSize = clampedRecordingCursorHighlightSize;

_settingsService.Save(new UserSettings
Expand All @@ -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();
}
Expand Down
Loading