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
28 changes: 14 additions & 14 deletions Pointframe.Tests/Services/ActivationTelemetryServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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));
}
}
64 changes: 64 additions & 0 deletions Pointframe.Tests/Services/TelemetryEventCatalogTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>
{
[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);
}
}
93 changes: 77 additions & 16 deletions Pointframe.Tests/Services/TelemetryServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
{
[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<string, string>
{
[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<string, string>
{
[TelemetryPropertyKeys.Type] = "region",
});

Assert.Contains(logger.Entries, entry => entry.Level == LogLevel.Warning);
}

[Fact]
public void TrackEvent_MessageContainsEventName()
{
Expand All @@ -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]
Expand All @@ -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"]);
Expand All @@ -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"]);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -217,7 +260,11 @@ public void TrackEvent_IncludesAdditionalPropertiesInScope()
var sut = CreateSut(logger);

// Act
sut.TrackEvent("snip_started", new Dictionary<string, string> { ["type"] = "region" });
sut.TrackEvent(TelemetryEvents.SnipStarted, new Dictionary<string, string>
{
[TelemetryPropertyKeys.Type] = "region",
[TelemetryPropertyKeys.Source] = "tray",
});

// Assert
Assert.Equal("region", logger.Entries[0].Scope["type"]);
Expand All @@ -231,7 +278,10 @@ public void TrackEvent_AdditionalPropertiesCoexistWithInstallId()
var sut = CreateSut(logger, installId: "xyz");

// Act
sut.TrackEvent("recording_started", new Dictionary<string, string> { ["type"] = "whole_screen" });
sut.TrackEvent(TelemetryEvents.RecordingStarted, new Dictionary<string, string>
{
[TelemetryPropertyKeys.Type] = "whole_screen",
});

// Assert
var scope = logger.Entries[0].Scope;
Expand All @@ -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()
{
Expand Down Expand Up @@ -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"));
Expand All @@ -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"];
Expand All @@ -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"]);
Expand All @@ -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]
Expand Down
25 changes: 14 additions & 11 deletions Pointframe/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,11 @@ protected override void OnStartup(StartupEventArgs e)
{
var version = _host.Services.GetRequiredService<IAppVersionService>().Current;
_sessionStartTime = DateTime.UtcNow;
_telemetry.TrackEvent("app_started", new Dictionary<string, string>
_telemetry.TrackEvent(TelemetryEvents.AppStarted, new Dictionary<string, string>
{
["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(),
});
}

Expand All @@ -143,9 +143,9 @@ protected override void OnStartup(StartupEventArgs e)
_trayIconManager = _host.Services.GetRequiredService<ITrayIconManager>();
_trayIconManager.Initialize();
startupTimer.Stop();
_telemetry.TrackEvent("startup_completed", new Dictionary<string, string>
_telemetry.TrackEvent(TelemetryEvents.StartupCompleted, new Dictionary<string, string>
{
["duration_ms"] = startupTimer.ElapsedMilliseconds.ToString(),
[TelemetryPropertyKeys.DurationMilliseconds] = startupTimer.ElapsedMilliseconds.ToString(),
});
#if DEBUG
_trayIconManager.AddDebugMenuItems();
Expand All @@ -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<string, string>
_telemetry?.TrackEvent(TelemetryEvents.AppClosed, new Dictionary<string, string>
{
["session_minutes"] = ((int)(DateTime.UtcNow - _sessionStartTime).TotalMinutes).ToString(),
[TelemetryPropertyKeys.SessionMinutes] = ((int)(DateTime.UtcNow - _sessionStartTime).TotalMinutes).ToString(),
});
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -398,7 +398,10 @@ private async ValueTask HandleUpdateAvailable(UpdateAvailableMessage message)
}

var v = message.Result.LatestVersion;
_telemetry.TrackEvent("update_available", new Dictionary<string, string> { ["version"] = $"{v.Major}.{v.Minor}.{v.Build}" });
_telemetry.TrackEvent(TelemetryEvents.UpdateAvailable, new Dictionary<string, string>
{
[TelemetryPropertyKeys.Version] = $"{v.Major}.{v.Minor}.{v.Build}",
});
}

private ValueTask HandleOpenImageRequested(OpenImageRequestedMessage message)
Expand Down
3 changes: 2 additions & 1 deletion Pointframe/AppServiceRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ internal static IServiceCollection AddPointframeAppServices(this IServiceCollect
screenRecordingService,
outputPath,
sp.GetRequiredService<IEventAggregator>(),
sp.GetRequiredService<ILogger<RecordingHudViewModel>>()));
sp.GetRequiredService<ILogger<RecordingHudViewModel>>(),
sp.GetRequiredService<ITelemetryService>()));
services.AddTransient<AboutViewModel>();
services.AddTransient<AboutWindow>();
services.AddTransient<LibraryWindow>();
Expand Down
Loading
Loading