Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
06ad3e1
Add telemetry collection for MSTest usage analytics
Evangelink Mar 17, 2026
b609215
Fix Convert.ToHexString for .NET Framework targets
Evangelink Mar 17, 2026
3e6f8b5
Suppress IDE0032 for Volatile.Read/Write backing field
Evangelink Mar 17, 2026
6265e4f
Move GetAwaiter().GetResult() to MSTestDiscoverer call site
Evangelink Mar 17, 2026
f1c1fa5
Gate telemetry collection on TESTINGPLATFORM_TELEMETRY_OPTOUT env vars
Evangelink Mar 17, 2026
cbf9140
Fix telemetry lifecycle and handler coverage gaps
Evangelink Mar 17, 2026
2a26a81
Fix TelemetryTests: regex pattern, VSTest global.json, and workingDir…
Evangelink Mar 18, 2026
6259d0e
Address PR review feedback
Evangelink Mar 18, 2026
07a82f9
Address human reviewer feedback
Evangelink Mar 18, 2026
4820a42
Merge main into copilot/telemetry-collection-fixes
Evangelink Apr 17, 2026
ecb2b57
Fix build after merge with main
Evangelink Apr 17, 2026
298cf4f
Exclude vstest/ subdirectory from MTP project compilation
Evangelink Apr 17, 2026
7c991f3
Merge main into PR #7570
Copilot May 13, 2026
ce1b671
Fix telemetry build issues after main merge
Copilot May 13, 2026
0c536a3
Merge remote-tracking branch 'origin/main' into copilot/telemetry-col…
Copilot May 13, 2026
6990087
Add TrackAssertionCall to refactored Assert/CollectionAssert partial …
Copilot May 13, 2026
1d6410b
Merge remote-tracking branch 'origin/copilot/telemetry-collection-fix…
Copilot May 13, 2026
0499d4e
Merge remote-tracking branch 'origin/copilot/telemetry-collection-fix…
Copilot May 13, 2026
94e512b
Fix net462 build: replace Order(IComparer) with OrderBy
Evangelink May 13, 2026
0f8c146
Address PR review comments
May 14, 2026
6f784b2
Merge main and resolve conflicts in Assert partial files and TypeEnum…
May 15, 2026
d77a3b3
Address expert review findings: H1 H2 M1 M2 M3 M4 L1
May 15, 2026
5bcf948
Use System.Threading.Lock on net9.0 to satisfy IDE0330
May 15, 2026
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
5 changes: 5 additions & 0 deletions src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
<DefineConstants>$(DefineConstants);WINDOWS_UWP</DefineConstants>
</PropertyGroup>

<!-- Properties specific to WinUI -->
<PropertyGroup Condition=" '$(TargetFramework)' == '$(WinUiMinimum)' ">
<DefineConstants>$(DefineConstants);WIN_UI</DefineConstants>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="$(RepoRoot)src\Adapter\MSTestAdapter.PlatformServices\MSTestAdapter.PlatformServices.csproj" />
<ProjectReference Include="$(RepoRoot)src\Platform\Microsoft.Testing.Extensions.VSTestBridge\Microsoft.Testing.Extensions.VSTestBridge.csproj" Condition=" '$(TargetFramework)' != '$(UwpMinimum)' " />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
using Microsoft.Testing.Platform.Logging;
using Microsoft.Testing.Platform.Messages;
using Microsoft.Testing.Platform.Services;
using Microsoft.Testing.Platform.Telemetry;
using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Extensions;
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices;
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Helpers;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;

Expand Down Expand Up @@ -41,8 +43,7 @@ protected override Task SynchronizedDiscoverTestsAsync(VSTestDiscoverTestExecuti
Debugger.Launch();
}

new MSTestDiscoverer().DiscoverTests(request.AssemblyPaths, request.DiscoveryContext, request.MessageLogger, request.DiscoverySink, _configuration, isMTP: true);
return Task.CompletedTask;
return new MSTestDiscoverer(new TestSourceHandler(), CreateTelemetrySender()).DiscoverTestsAsync(request.AssemblyPaths, request.DiscoveryContext, request.MessageLogger, request.DiscoverySink, _configuration, isMTP: true);
}

/// <inheritdoc />
Expand All @@ -55,7 +56,7 @@ protected override async Task SynchronizedRunTestsAsync(VSTestRunTestExecutionRe
Debugger.Launch();
}

MSTestExecutor testExecutor = new(cancellationToken);
MSTestExecutor testExecutor = new(cancellationToken, CreateTelemetrySender());
await testExecutor.RunTestsAsync(request.AssemblyPaths, request.RunContext, request.FrameworkHandle, _configuration, isMTP: true).ConfigureAwait(false);
}

Expand Down Expand Up @@ -103,5 +104,19 @@ private static TestMethodIdentifierProperty GetMethodIdentifierPropertyFromManag
// Or alternatively, does VSTest object model expose the assembly full name somewhere?
return new TestMethodIdentifierProperty(assemblyFullName: string.Empty, @namespace, typeName, methodName, arity, parameterTypes, returnTypeFullName: string.Empty);
}

[SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "We can use MTP from this folder")]
private Func<string, IDictionary<string, object>, Task>? CreateTelemetrySender()
{
ITelemetryInformation telemetryInformation = ServiceProvider.GetTelemetryInformation();
if (!telemetryInformation.IsEnabled)
{
return null;
}

ITelemetryCollector telemetryCollector = ServiceProvider.GetTelemetryCollector();

return (eventName, metrics) => telemetryCollector.LogEventAsync(eventName, metrics, CancellationToken.None);
}
}
#endif
50 changes: 44 additions & 6 deletions src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestDiscoverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,27 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
internal sealed class MSTestDiscoverer : ITestDiscoverer
{
private readonly ITestSourceHandler _testSourceHandler;
#if !WINDOWS_UWP && !WIN_UI
private readonly Func<string, IDictionary<string, object>, Task>? _telemetrySender;
#endif

// The parameterless constructor is required by VSTest, which instantiates the
// discoverer via reflection. The internal constructor exists for tests and for the
// MTP bridge (MSTestBridgedTestFramework) which injects a telemetry sender.
public MSTestDiscoverer()
Comment thread
Evangelink marked this conversation as resolved.
: this(new TestSourceHandler())
{
}

internal /* for testing purposes */ MSTestDiscoverer(ITestSourceHandler testSourceHandler)
=> _testSourceHandler = testSourceHandler;
internal MSTestDiscoverer(ITestSourceHandler testSourceHandler, Func<string, IDictionary<string, object>, Task>? telemetrySender = null)
{
_testSourceHandler = testSourceHandler;
#if !WINDOWS_UWP && !WIN_UI
_telemetrySender = telemetrySender;
#else
_ = telemetrySender;
#endif
}

/// <summary>
/// Discovers the tests available from the provided source. Not supported for .xap source.
Expand All @@ -39,9 +52,12 @@ public MSTestDiscoverer()
[System.Security.SecurityCritical]
[SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "Discovery context can be null.")]
public void DiscoverTests(IEnumerable<string> sources, IDiscoveryContext discoveryContext, IMessageLogger logger, ITestCaseDiscoverySink discoverySink)
=> DiscoverTests(sources, discoveryContext, logger, discoverySink, null, isMTP: false);
// VSTest's ITestDiscoverer is a synchronous interface. The telemetry sender is null in
// this code path (only the MTP bridge supplies one), so the awaited send below completes
// synchronously and GetAwaiter().GetResult() does not actually block on I/O.
=> DiscoverTestsAsync(sources, discoveryContext, logger, discoverySink, configuration: null, isMTP: false).GetAwaiter().GetResult();
Comment thread
Evangelink marked this conversation as resolved.

internal void DiscoverTests(IEnumerable<string> sources, IDiscoveryContext discoveryContext, IMessageLogger logger, ITestCaseDiscoverySink discoverySink, IConfiguration? configuration, bool isMTP)
internal async Task DiscoverTestsAsync(IEnumerable<string> sources, IDiscoveryContext discoveryContext, IMessageLogger logger, ITestCaseDiscoverySink discoverySink, IConfiguration? configuration, bool isMTP)
{
if (sources is null)
{
Expand All @@ -58,9 +74,31 @@ internal void DiscoverTests(IEnumerable<string> sources, IDiscoveryContext disco
throw new ArgumentNullException(nameof(discoverySink));
}

if (MSTestDiscovererHelpers.InitializeDiscovery(sources, discoveryContext, logger, configuration, _testSourceHandler))
// Initialize telemetry collection if not already set (e.g. first call in the session).
#if !WINDOWS_UWP && !WIN_UI
if (!MSTestTelemetryDataCollector.IsTelemetryOptedOut())
{
_ = MSTestTelemetryDataCollector.EnsureInitialized();
}
#endif

try
{
if (MSTestDiscovererHelpers.InitializeDiscovery(sources, discoveryContext, logger, configuration, _testSourceHandler))
{
new UnitTestDiscoverer(_testSourceHandler).DiscoverTests(sources, logger, discoverySink, discoveryContext, isMTP);
}
}
finally
{
new UnitTestDiscoverer(_testSourceHandler).DiscoverTests(sources, logger, discoverySink, discoveryContext, isMTP);
#if !WINDOWS_UWP && !WIN_UI
// Send the discovery telemetry event ('mstest/discovery'). This always runs at the
// end of discovery — for discover-only sessions it is the only event; for sessions
// where a run follows, MSTestExecutor will send a separate 'mstest/sessionexit' event
// carrying assertion usage. Keeping the two events distinct avoids settings/attribute
// duplication and lets each event be self-contained.
await MSTestTelemetryDataCollector.SendDiscoveryTelemetryAndResetAsync(_telemetrySender).ConfigureAwait(false);
#endif
}
}
}
64 changes: 55 additions & 9 deletions src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
internal sealed class MSTestExecutor : ITestExecutor
{
private readonly CancellationToken _cancellationToken;
#if !WINDOWS_UWP && !WIN_UI
private readonly Func<string, IDictionary<string, object>, Task>? _telemetrySender;
#endif

/// <summary>
/// Token for canceling the test run.
Expand All @@ -35,10 +38,15 @@ public MSTestExecutor()
_cancellationToken = CancellationToken.None;
}

internal MSTestExecutor(CancellationToken cancellationToken)
internal MSTestExecutor(CancellationToken cancellationToken, Func<string, IDictionary<string, object>, Task>? telemetrySender = null)
{
TestExecutionManager = new TestExecutionManager();
_cancellationToken = cancellationToken;
#if !WINDOWS_UWP && !WIN_UI
_telemetrySender = telemetrySender;
#else
_ = telemetrySender;
#endif
}

/// <summary>
Expand Down Expand Up @@ -119,12 +127,27 @@ internal async Task RunTestsAsync(IEnumerable<TestCase>? tests, IRunContext? run

Ensure.NotEmpty(tests);

Comment thread
Evangelink marked this conversation as resolved.
if (!MSTestDiscovererHelpers.InitializeDiscovery(from test in tests select test.Source, runContext, frameworkHandle, configuration, new TestSourceHandler()))
// Initialize telemetry collection if not already set
#if !WINDOWS_UWP && !WIN_UI
if (!MSTestTelemetryDataCollector.IsTelemetryOptedOut())
{
return;
_ = MSTestTelemetryDataCollector.EnsureInitialized();
}
#endif

try
{
if (!MSTestDiscovererHelpers.InitializeDiscovery(from test in tests select test.Source, runContext, frameworkHandle, configuration, new TestSourceHandler()))
{
return;
}

await RunTestsFromRightContextAsync(frameworkHandle, async testRunToken => await TestExecutionManager.RunTestsAsync(tests, runContext, frameworkHandle, testRunToken).ConfigureAwait(false)).ConfigureAwait(false);
await RunTestsFromRightContextAsync(frameworkHandle, async testRunToken => await TestExecutionManager.RunTestsAsync(tests, runContext, frameworkHandle, testRunToken).ConfigureAwait(false)).ConfigureAwait(false);
}
finally
{
await SendTelemetryAsync().ConfigureAwait(false);
}
}

internal async Task RunTestsAsync(IEnumerable<string>? sources, IRunContext? runContext, IFrameworkHandle? frameworkHandle, IConfiguration? configuration, bool isMTP)
Expand All @@ -147,14 +170,29 @@ internal async Task RunTestsAsync(IEnumerable<string>? sources, IRunContext? run

Ensure.NotEmpty(sources);

TestSourceHandler testSourceHandler = new();
if (!MSTestDiscovererHelpers.InitializeDiscovery(sources, runContext, frameworkHandle, configuration, testSourceHandler))
// Initialize telemetry collection if not already set
#if !WINDOWS_UWP && !WIN_UI
if (!MSTestTelemetryDataCollector.IsTelemetryOptedOut())
{
return;
_ = MSTestTelemetryDataCollector.EnsureInitialized();
}
#endif

try
{
TestSourceHandler testSourceHandler = new();
if (!MSTestDiscovererHelpers.InitializeDiscovery(sources, runContext, frameworkHandle, configuration, testSourceHandler))
{
return;
}

sources = testSourceHandler.GetTestSources(sources);
await RunTestsFromRightContextAsync(frameworkHandle, async testRunToken => await TestExecutionManager.RunTestsAsync(sources, runContext, frameworkHandle, testSourceHandler, isMTP, testRunToken).ConfigureAwait(false)).ConfigureAwait(false);
sources = testSourceHandler.GetTestSources(sources);
await RunTestsFromRightContextAsync(frameworkHandle, async testRunToken => await TestExecutionManager.RunTestsAsync(sources, runContext, frameworkHandle, testSourceHandler, isMTP, testRunToken).ConfigureAwait(false)).ConfigureAwait(false);
}
finally
{
await SendTelemetryAsync().ConfigureAwait(false);
}
}

/// <summary>
Expand All @@ -163,6 +201,14 @@ internal async Task RunTestsAsync(IEnumerable<string>? sources, IRunContext? run
public void Cancel()
=> _testRunCancellationToken?.Cancel();

#if !WINDOWS_UWP && !WIN_UI
private Task SendTelemetryAsync()
=> MSTestTelemetryDataCollector.SendExecutionTelemetryAndResetAsync(_telemetrySender);
#else
private static Task SendTelemetryAsync()
=> Task.CompletedTask;
#endif

private async Task RunTestsFromRightContextAsync(IFrameworkHandle frameworkHandle, Func<TestRunCancellationToken, Task> runTestsAction)
{
ApartmentState? requestedApartmentState = MSTestSettings.RunConfigurationSettings.ExecutionApartmentState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ internal TypeEnumerator(Type type, string assemblyFilePath, ReflectHelper reflec
return null;
}

// Track class-level attributes for telemetry (read Current per call so a session reset
// between TypeEnumerator construction and use cannot cause writes to land on an
// orphaned collector).
#if !WINDOWS_UWP && !WIN_UI
if (Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestTelemetryDataCollector.Current is { } telemetryDataCollector)
{
Attribute[] classAttributes = ReflectHelper.GetCustomAttributesCached(_type);
telemetryDataCollector.TrackDiscoveredClass(classAttributes);
}
#endif

// If test class is valid, then get the tests
return GetTests(warnings);
}
Expand Down Expand Up @@ -151,6 +162,9 @@ internal UnitTestElement GetTestFromMethod(MethodInfo method, bool classDisables
};

Attribute[] attributes = reflectionOperations.GetCustomAttributesCached(method);
#if !WINDOWS_UWP && !WIN_UI
Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestTelemetryDataCollector.Current?.TrackDiscoveredMethod(attributes);
#endif
TestMethodAttribute? testMethodAttribute = null;
List<string>? workItemIds = null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ internal static void PopulateSettings(IDiscoveryContext? context, IMessageLogger

CurrentSettings = settings;
RunConfigurationSettings = runConfigurationSettings;

// Track configuration source for telemetry.
#if !WINDOWS_UWP && !WIN_UI
if (MSTestTelemetryDataCollector.Current is { } telemetry)
{
telemetry.ConfigurationSource = configuration?["mstest"] is not null
? "testconfig.json"
: !StringEx.IsNullOrEmpty(context?.RunSettings?.SettingsXml)
? "runsettings"
: "none";
}
#endif
}

private static void SetGlobalSettings(string runsettingsXml, MSTestSettings settings, IMessageLogger? logger)
Expand Down
Loading