Compare commits

...

7 Commits

18 changed files with 397 additions and 20 deletions

View File

@@ -262,6 +262,7 @@ _If you want to find diagnostic data events in the source code, these two links
</table>
### Command Palette
<table style="width:100%">
<tr>
<th>Event Name</th>
@@ -315,6 +316,14 @@ _If you want to find diagnostic data events in the source code, these two links
<td>Microsoft.PowerToys.CmdPalProcessStarted</td>
<td>Triggered when the Command Palette process is started.</td>
</tr>
<tr>
<td>Microsoft.PowerToys.CmdPal_ExtensionInvoked</td>
<td>Tracks extension usage including extension ID, command details, success status, and execution time.</td>
</tr>
<tr>
<td>Microsoft.PowerToys.CmdPal_SessionDuration</td>
<td>Logs session metrics from launch to dismissal including duration, commands executed, pages visited, search queries, navigation depth, and errors.</td>
</tr>
</table>
### Crop And Lock

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Message sent when an error occurs during command execution.
/// Used to track session error count for telemetry.
/// </summary>
public record ErrorOccurredMessage();

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Message sent when an extension command or page is invoked.
/// Captures extension usage metrics for telemetry tracking.
/// </summary>
public record ExtensionInvokedMessage(string ExtensionId, string CommandId, string CommandName, bool Success, ulong ExecutionTimeMs);

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Message containing the current navigation depth (BackStack count) when navigating to a page.
/// Used to track maximum navigation depth reached during a session for telemetry.
/// </summary>
public record NavigationDepthMessage(int Depth);

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Message sent when a search query is executed in the Command Palette.
/// Used to track session search activity for telemetry.
/// </summary>
public record SearchQueryMessage();

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Message containing session telemetry data from Command Palette launch to dismissal.
/// Used to aggregate metrics like duration, commands executed, pages visited, and search activity.
/// </summary>
public record SessionDurationMessage(ulong DurationMs, int CommandsExecuted, int PagesVisited, string DismissalReason, int SearchQueriesCount, int MaxNavigationDepth, int ErrorCount);

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Telemetry message sent when command invocation begins.
/// </summary>
public record TelemetryBeginInvokeMessage;

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Telemetry message sent when an extension command or page is invoked.
/// Captures extension usage metrics for telemetry tracking.
/// </summary>
public record TelemetryExtensionInvokedMessage(string ExtensionId, string CommandId, string CommandName, bool Success, ulong ExecutionTimeMs);

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
/// <summary>
/// Telemetry message sent when command invocation completes with a result.
/// </summary>
public record TelemetryInvokeResultMessage(Microsoft.CommandPalette.Extensions.CommandResultKind Kind);

View File

@@ -270,8 +270,18 @@ public partial class ShellViewModel : ObservableObject,
var isMainPage = command == _rootPage;
_isNested = !isMainPage;
// Telemetry: Track extension page navigation for session metrics
if (host is not null)
{
string extensionId = host.GetExtensionDisplayName() ?? "builtin";
string commandId = command?.Id ?? "unknown";
string commandName = command?.Name ?? "unknown";
WeakReferenceMessenger.Default.Send<TelemetryExtensionInvokedMessage>(
new(extensionId, commandId, commandName, true, 0));
}
// Construct our ViewModel of the appropriate type and pass it the UI Thread context.
var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, _isNested, host);
var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, _isNested, host!);
if (pageViewModel is null)
{
CoreLogger.LogError($"Failed to create ViewModel for page {page.GetType().Name}");
@@ -306,7 +316,7 @@ public partial class ShellViewModel : ObservableObject,
{
CoreLogger.LogDebug($"Invoking command");
WeakReferenceMessenger.Default.Send<BeginInvokeMessage>();
WeakReferenceMessenger.Default.Send<TelemetryBeginInvokeMessage>();
StartInvoke(message, invokable, host);
}
}
@@ -339,6 +349,14 @@ public partial class ShellViewModel : ObservableObject,
private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host)
{
// Telemetry: Track command execution time and success
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var command = message.Command.Unsafe;
string extensionId = host?.GetExtensionDisplayName() ?? "builtin";
string commandId = command?.Id ?? "unknown";
string commandName = command?.Name ?? "unknown";
bool success = false;
try
{
// Call out to extension process.
@@ -349,16 +367,28 @@ public partial class ShellViewModel : ObservableObject,
// But if it did succeed, we need to handle the result.
UnsafeHandleCommandResult(result);
success = true;
_handleInvokeTask = null;
}
catch (Exception ex)
{
success = false;
_handleInvokeTask = null;
// Telemetry: Track errors for session metrics
WeakReferenceMessenger.Default.Send<ErrorOccurredMessage>(new());
// TODO: It would be better to do this as a page exception, rather
// than a silent log message.
host?.Log(ex.Message);
}
finally
{
// Telemetry: Send extension invocation metrics (always sent, even on failure)
stopwatch.Stop();
WeakReferenceMessenger.Default.Send<TelemetryExtensionInvokedMessage>(
new(extensionId, commandId, commandName, success, (ulong)stopwatch.ElapsedMilliseconds));
}
}
private void UnsafeHandleCommandResult(ICommandResult? result)
@@ -372,7 +402,7 @@ public partial class ShellViewModel : ObservableObject,
var kind = result.Kind;
CoreLogger.LogDebug($"handling {kind.ToString()}");
WeakReferenceMessenger.Default.Send<CmdPalInvokeResultMessage>(new(kind));
WeakReferenceMessenger.Default.Send<TelemetryInvokeResultMessage>(new(kind));
switch (kind)
{
case CommandResultKind.Dismiss:

View File

@@ -34,6 +34,6 @@ public sealed partial class CommandPaletteHost : AppExtensionHost, IExtensionHos
public override string? GetExtensionDisplayName()
{
return Extension?.ExtensionDisplayName;
return Extension?.ExtensionDisplayName ?? _builtInProvider?.DisplayName ?? _builtInProvider?.Id;
}
}

View File

@@ -379,6 +379,12 @@ public sealed partial class SearchBar : UserControl,
if (CurrentPageViewModel is not null)
{
CurrentPageViewModel.SearchTextBox = FilterBox.Text;
// Telemetry: Track search query count for session metrics (only non-empty queries)
if (!string.IsNullOrWhiteSpace(FilterBox.Text))
{
WeakReferenceMessenger.Default.Send<SearchQueryMessage>(new());
}
}
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Tracing;
using Microsoft.PowerToys.Telemetry;
using Microsoft.PowerToys.Telemetry.Events;
namespace Microsoft.CmdPal.UI.Events;
/// <summary>
/// Tracks extension usage with extension name and invocation details.
/// Purpose: Identify popular vs. unused plugins and track extension performance.
/// </summary>
[EventData]
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
public class CmdPalExtensionInvoked : EventBase, IEvent
{
/// <summary>
/// Gets or sets the unique identifier of the extension provider.
/// </summary>
public string ExtensionId { get; set; }
/// <summary>
/// Gets or sets the non-localized identifier of the command being invoked.
/// </summary>
public string CommandId { get; set; }
/// <summary>
/// Gets or sets the localized display name of the command being invoked.
/// </summary>
public string CommandName { get; set; }
/// <summary>
/// Gets or sets whether the command executed successfully.
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Gets or sets the execution time in milliseconds.
/// </summary>
public ulong ExecutionTimeMs { get; set; }
public CmdPalExtensionInvoked(string extensionId, string commandId, string commandName, bool success, ulong executionTimeMs)
{
EventName = "CmdPal_ExtensionInvoked";
ExtensionId = extensionId;
CommandId = commandId;
CommandName = commandName;
Success = success;
ExecutionTimeMs = executionTimeMs;
}
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
}

View File

@@ -18,6 +18,7 @@ public class CmdPalInvokeResult : EventBase, IEvent
public CmdPalInvokeResult(CommandResultKind resultKind)
{
EventName = "CmdPal_InvokeResult";
ResultKind = resultKind.ToString();
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Tracing;
using Microsoft.PowerToys.Telemetry;
using Microsoft.PowerToys.Telemetry.Events;
namespace Microsoft.CmdPal.UI.Events;
/// <summary>
/// Tracks Command Palette session duration from launch to close.
/// Purpose: Understand user engagement patterns - quick actions vs. browsing behavior.
/// </summary>
[EventData]
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
public class CmdPalSessionDuration : EventBase, IEvent
{
/// <summary>
/// Gets or sets the session duration in milliseconds.
/// </summary>
public ulong DurationMs { get; set; }
/// <summary>
/// Gets or sets the number of commands executed during the session.
/// </summary>
public int CommandsExecuted { get; set; }
/// <summary>
/// Gets or sets the number of pages visited during the session.
/// </summary>
public int PagesVisited { get; set; }
/// <summary>
/// Gets or sets the reason for dismissal (Escape, LostFocus, Command, etc.).
/// </summary>
public string DismissalReason { get; set; }
/// <summary>
/// Gets or sets the number of search queries executed during the session.
/// </summary>
public int SearchQueriesCount { get; set; }
/// <summary>
/// Gets or sets the maximum navigation depth reached during the session.
/// </summary>
public int MaxNavigationDepth { get; set; }
/// <summary>
/// Gets or sets the number of errors encountered during the session.
/// </summary>
public int ErrorCount { get; set; }
public CmdPalSessionDuration(ulong durationMs, int commandsExecuted, int pagesVisited, string dismissalReason, int searchQueriesCount, int maxNavigationDepth, int errorCount)
{
EventName = "CmdPal_SessionDuration";
DurationMs = durationMs;
CommandsExecuted = commandsExecuted;
PagesVisited = pagesVisited;
DismissalReason = dismissalReason;
SearchQueriesCount = searchQueriesCount;
MaxNavigationDepth = maxNavigationDepth;
ErrorCount = errorCount;
}
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
}

View File

@@ -6,40 +6,78 @@ using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CommandPalette.Extensions;
using Microsoft.PowerToys.Telemetry;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// TelemetryForwarder is responsible for forwarding telemetry events from the
/// command palette core to PowerToys Telemetry.
/// This allows us to emit telemetry events as messages from the core,
/// and then handle them by logging to our PT telemetry provider.
///
/// We may in the future want to replace this with a more generic "ITelemetryService"
/// or something similar, but this works for now.
/// command palette to PowerToys Telemetry.
/// Listens to telemetry-specific messages from the core layer and logs them to PowerToys telemetry.
/// Also implements ITelemetryService for dependency injection in extensions.
/// </summary>
internal sealed class TelemetryForwarder :
ITelemetryService,
IRecipient<BeginInvokeMessage>,
IRecipient<CmdPalInvokeResultMessage>
IRecipient<TelemetryBeginInvokeMessage>,
IRecipient<TelemetryInvokeResultMessage>,
IRecipient<TelemetryExtensionInvokedMessage>
{
public TelemetryForwarder()
{
WeakReferenceMessenger.Default.Register<BeginInvokeMessage>(this);
WeakReferenceMessenger.Default.Register<CmdPalInvokeResultMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetryBeginInvokeMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetryInvokeResultMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetryExtensionInvokedMessage>(this);
}
public void Receive(CmdPalInvokeResultMessage message)
{
PowerToysTelemetry.Log.WriteEvent(new CmdPalInvokeResult(message.Kind));
}
public void Receive(BeginInvokeMessage message)
// Message handlers for telemetry events from core layer
public void Receive(TelemetryBeginInvokeMessage message)
{
PowerToysTelemetry.Log.WriteEvent(new BeginInvoke());
}
public void Receive(TelemetryInvokeResultMessage message)
{
PowerToysTelemetry.Log.WriteEvent(new CmdPalInvokeResult(message.Kind));
}
public void Receive(TelemetryExtensionInvokedMessage message)
{
PowerToysTelemetry.Log.WriteEvent(new CmdPalExtensionInvoked(
message.ExtensionId,
message.CommandId,
message.CommandName,
message.Success,
message.ExecutionTimeMs));
// Increment session counter for commands executed
if (App.Current.AppWindow is MainWindow mainWindow)
{
mainWindow.IncrementCommandsExecuted();
}
}
// Static method for logging session duration from UI layer
public static void LogSessionDuration(
ulong durationMs,
int commandsExecuted,
int pagesVisited,
string dismissalReason,
int searchQueriesCount,
int maxNavigationDepth,
int errorCount)
{
PowerToysTelemetry.Log.WriteEvent(new CmdPalSessionDuration(
durationMs,
commandsExecuted,
pagesVisited,
dismissalReason,
searchQueriesCount,
maxNavigationDepth,
errorCount));
}
// ITelemetryService implementation for dependency injection in extensions
public void LogRunQuery(string query, int resultCount, ulong durationMs)
{
PowerToysTelemetry.Log.WriteEvent(new CmdPalRunQuery(query, resultCount, durationMs));

View File

@@ -52,6 +52,10 @@ public sealed partial class MainWindow : WindowEx,
IRecipient<ShowWindowMessage>,
IRecipient<HideWindowMessage>,
IRecipient<QuitMessage>,
IRecipient<NavigateToPageMessage>,
IRecipient<NavigationDepthMessage>,
IRecipient<SearchQueryMessage>,
IRecipient<ErrorOccurredMessage>,
IRecipient<DragStartedMessage>,
IRecipient<DragCompletedMessage>,
IDisposable
@@ -75,6 +79,14 @@ public sealed partial class MainWindow : WindowEx,
private bool _ignoreHotKeyWhenFullScreen = true;
private bool _themeServiceInitialized;
// Session tracking for telemetry
private Stopwatch? _sessionStopwatch;
private int _sessionCommandsExecuted;
private int _sessionPagesVisited;
private int _sessionSearchQueriesCount;
private int _sessionMaxNavigationDepth;
private int _sessionErrorCount;
private DesktopAcrylicController? _acrylicController;
private SystemBackdropConfiguration? _configurationSource;
private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan;
@@ -123,6 +135,10 @@ public sealed partial class MainWindow : WindowEx,
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
WeakReferenceMessenger.Default.Register<ShowWindowMessage>(this);
WeakReferenceMessenger.Default.Register<HideWindowMessage>(this);
WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this);
WeakReferenceMessenger.Default.Register<NavigationDepthMessage>(this);
WeakReferenceMessenger.Default.Register<SearchQueryMessage>(this);
WeakReferenceMessenger.Default.Register<ErrorOccurredMessage>(this);
WeakReferenceMessenger.Default.Register<DragStartedMessage>(this);
WeakReferenceMessenger.Default.Register<DragCompletedMessage>(this);
@@ -524,6 +540,11 @@ public sealed partial class MainWindow : WindowEx,
{
var settings = App.Current.Services.GetService<SettingsModel>()!;
// Start session tracking
_sessionStopwatch = Stopwatch.StartNew();
_sessionCommandsExecuted = 0;
_sessionPagesVisited = 0;
ShowHwnd(message.Hwnd, settings.SummonOn);
}
@@ -532,6 +553,7 @@ public sealed partial class MainWindow : WindowEx,
// This might come in off the UI thread. Make sure to hop back.
DispatcherQueue.TryEnqueue(() =>
{
EndSession("Hide");
HideWindow();
});
}
@@ -551,10 +573,67 @@ public sealed partial class MainWindow : WindowEx,
// This might come in off the UI thread. Make sure to hop back.
DispatcherQueue.TryEnqueue(() =>
{
EndSession("Dismiss");
HideWindow();
});
}
// Session telemetry: Track metrics during the Command Palette session
// These receivers increment counters that are sent when EndSession is called
public void Receive(NavigateToPageMessage message)
{
_sessionPagesVisited++;
}
public void Receive(NavigationDepthMessage message)
{
if (message.Depth > _sessionMaxNavigationDepth)
{
_sessionMaxNavigationDepth = message.Depth;
}
}
public void Receive(SearchQueryMessage message)
{
_sessionSearchQueriesCount++;
}
public void Receive(ErrorOccurredMessage message)
{
_sessionErrorCount++;
}
/// <summary>
/// Ends the current telemetry session and emits the CmdPal_SessionDuration event.
/// Aggregates all session metrics collected since ShowWindow and sends them to telemetry.
/// </summary>
/// <param name="dismissalReason">The reason the session ended (e.g., Dismiss, Hide, LostFocus).</param>
private void EndSession(string dismissalReason)
{
if (_sessionStopwatch is not null)
{
_sessionStopwatch.Stop();
TelemetryForwarder.LogSessionDuration(
(ulong)_sessionStopwatch.ElapsedMilliseconds,
_sessionCommandsExecuted,
_sessionPagesVisited,
dismissalReason,
_sessionSearchQueriesCount,
_sessionMaxNavigationDepth,
_sessionErrorCount);
_sessionStopwatch = null;
}
}
/// <summary>
/// Increments the session commands executed counter for telemetry.
/// Called by TelemetryForwarder when an extension command is invoked.
/// </summary>
internal void IncrementCommandsExecuted()
{
_sessionCommandsExecuted++;
}
private void HideWindow()
{
// Cloak our HWND to avoid all animations.
@@ -764,6 +843,7 @@ public sealed partial class MainWindow : WindowEx,
}
// This will DWM cloak our window:
EndSession("LostFocus");
HideWindow();
PowerToysTelemetry.Log.WriteEvent(new CmdPalDismissedOnLostFocus());

View File

@@ -161,6 +161,9 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth, message.Page.Id));
// Telemetry: Send navigation depth for session max depth tracking
WeakReferenceMessenger.Default.Send(new NavigationDepthMessage(RootFrame.BackStackDepth));
if (!ViewModel.IsNested)
{
// todo BODGY