diff --git a/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj b/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj
index b6d063c7d2..d703a6f40c 100644
--- a/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj
+++ b/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj
@@ -51,6 +51,11 @@
$(DefineConstants);WINDOWS_UWP
+
+
+ $(DefineConstants);WIN_UI
+
+
diff --git a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestBridgedTestFramework.cs b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestBridgedTestFramework.cs
index 55e200cd73..2cb24b944d 100644
--- a/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestBridgedTestFramework.cs
+++ b/src/Adapter/MSTest.TestAdapter/TestingPlatformAdapter/MSTestBridgedTestFramework.cs
@@ -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;
@@ -41,7 +43,7 @@ protected override Task SynchronizedDiscoverTestsAsync(VSTestDiscoverTestExecuti
Debugger.Launch();
}
- new MSTestDiscoverer().DiscoverTests(request.AssemblyPaths, request.DiscoveryContext, request.MessageLogger, request.DiscoverySink, _configuration);
+ new MSTestDiscoverer(new TestSourceHandler(), CreateTelemetrySender()).DiscoverTests(request.AssemblyPaths, request.DiscoveryContext, request.MessageLogger, request.DiscoverySink, _configuration);
return Task.CompletedTask;
}
@@ -55,7 +57,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).ConfigureAwait(false);
}
@@ -103,5 +105,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, 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
diff --git a/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestDiscoverer.cs b/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestDiscoverer.cs
index 3a3f24bf76..a9b0500526 100644
--- a/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestDiscoverer.cs
+++ b/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestDiscoverer.cs
@@ -20,14 +20,24 @@ namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
internal sealed class MSTestDiscoverer : ITestDiscoverer
{
private readonly ITestSourceHandler _testSourceHandler;
+#if !WINDOWS_UWP && !WIN_UI
+ private readonly Func, Task>? _telemetrySender;
+#endif
public MSTestDiscoverer()
: this(new TestSourceHandler())
{
}
- internal /* for testing purposes */ MSTestDiscoverer(ITestSourceHandler testSourceHandler)
- => _testSourceHandler = testSourceHandler;
+ internal MSTestDiscoverer(ITestSourceHandler testSourceHandler, Func, Task>? telemetrySender = null)
+ {
+ _testSourceHandler = testSourceHandler;
+#if !WINDOWS_UWP && !WIN_UI
+ _telemetrySender = telemetrySender;
+#else
+ _ = telemetrySender;
+#endif
+ }
///
/// Discovers the tests available from the provided source. Not supported for .xap source.
@@ -47,9 +57,26 @@ internal void DiscoverTests(IEnumerable sources, IDiscoveryContext disco
Ensure.NotNull(logger);
Ensure.NotNull(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);
+ }
+ }
+ finally
{
- new UnitTestDiscoverer(_testSourceHandler).DiscoverTests(sources, logger, discoverySink, discoveryContext);
+#if !WINDOWS_UWP && !WIN_UI
+ MSTestTelemetryDataCollector.SendTelemetryAndResetAsync(_telemetrySender).GetAwaiter().GetResult();
+#endif
}
}
}
diff --git a/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestExecutor.cs b/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestExecutor.cs
index 8fcb749311..fc20eba8d4 100644
--- a/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestExecutor.cs
+++ b/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestExecutor.cs
@@ -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, Task>? _telemetrySender;
+#endif
///
/// Token for canceling the test run.
@@ -35,10 +38,15 @@ public MSTestExecutor()
_cancellationToken = CancellationToken.None;
}
- internal MSTestExecutor(CancellationToken cancellationToken)
+ internal MSTestExecutor(CancellationToken cancellationToken, Func, Task>? telemetrySender = null)
{
TestExecutionManager = new TestExecutionManager();
_cancellationToken = cancellationToken;
+#if !WINDOWS_UWP && !WIN_UI
+ _telemetrySender = telemetrySender;
+#else
+ _ = telemetrySender;
+#endif
}
///
@@ -105,12 +113,27 @@ internal async Task RunTestsAsync(IEnumerable? tests, IRunContext? run
Ensure.NotNull(frameworkHandle);
Ensure.NotNullOrEmpty(tests);
+ // Initialize telemetry collection if not already set
+#if !WINDOWS_UWP && !WIN_UI
+ if (!MSTestTelemetryDataCollector.IsTelemetryOptedOut())
+ {
+ _ = MSTestTelemetryDataCollector.EnsureInitialized();
+ }
+#endif
+
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);
+ try
+ {
+ 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? sources, IRunContext? runContext, IFrameworkHandle? frameworkHandle, IConfiguration? configuration)
@@ -123,6 +146,14 @@ internal async Task RunTestsAsync(IEnumerable? sources, IRunContext? run
Ensure.NotNull(frameworkHandle);
Ensure.NotNullOrEmpty(sources);
+ // Initialize telemetry collection if not already set
+#if !WINDOWS_UWP && !WIN_UI
+ if (!MSTestTelemetryDataCollector.IsTelemetryOptedOut())
+ {
+ _ = MSTestTelemetryDataCollector.EnsureInitialized();
+ }
+#endif
+
TestSourceHandler testSourceHandler = new();
if (!MSTestDiscovererHelpers.InitializeDiscovery(sources, runContext, frameworkHandle, configuration, testSourceHandler))
{
@@ -130,7 +161,14 @@ internal async Task RunTestsAsync(IEnumerable? sources, IRunContext? run
}
sources = testSourceHandler.GetTestSources(sources);
- await RunTestsFromRightContextAsync(frameworkHandle, async testRunToken => await TestExecutionManager.RunTestsAsync(sources, runContext, frameworkHandle, testSourceHandler, testRunToken).ConfigureAwait(false)).ConfigureAwait(false);
+ try
+ {
+ await RunTestsFromRightContextAsync(frameworkHandle, async testRunToken => await TestExecutionManager.RunTestsAsync(sources, runContext, frameworkHandle, testSourceHandler, testRunToken).ConfigureAwait(false)).ConfigureAwait(false);
+ }
+ finally
+ {
+ await SendTelemetryAsync().ConfigureAwait(false);
+ }
}
///
@@ -139,6 +177,14 @@ internal async Task RunTestsAsync(IEnumerable? sources, IRunContext? run
public void Cancel()
=> _testRunCancellationToken?.Cancel();
+#if !WINDOWS_UWP && !WIN_UI
+ private Task SendTelemetryAsync()
+ => MSTestTelemetryDataCollector.SendTelemetryAndResetAsync(_telemetrySender);
+#else
+ private static Task SendTelemetryAsync()
+ => Task.CompletedTask;
+#endif
+
private async Task RunTestsFromRightContextAsync(IFrameworkHandle frameworkHandle, Func runTestsAction)
{
ApartmentState? requestedApartmentState = MSTestSettings.RunConfigurationSettings.ExecutionApartmentState;
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Discovery/AssemblyEnumerator.cs b/src/Adapter/MSTestAdapter.PlatformServices/Discovery/AssemblyEnumerator.cs
index b3ea7a2df6..8e610696d4 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Discovery/AssemblyEnumerator.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Discovery/AssemblyEnumerator.cs
@@ -145,7 +145,11 @@ internal virtual TypeEnumerator GetTypeEnumerator(Type type, string assemblyFile
var typeValidator = new TypeValidator(ReflectHelper, discoverInternals);
var testMethodValidator = new TestMethodValidator(ReflectHelper, discoverInternals);
+#if !WINDOWS_UWP && !WIN_UI
+ return new TypeEnumerator(type, assemblyFileName, ReflectHelper, typeValidator, testMethodValidator, Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestTelemetryDataCollector.Current);
+#else
return new TypeEnumerator(type, assemblyFileName, ReflectHelper, typeValidator, testMethodValidator);
+#endif
}
private List DiscoverTestsInType(
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs b/src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs
index b151d6d594..dd8acb3eb8 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs
@@ -19,7 +19,22 @@ internal class TypeEnumerator
private readonly TypeValidator _typeValidator;
private readonly TestMethodValidator _testMethodValidator;
private readonly ReflectHelper _reflectHelper;
+#if !WINDOWS_UWP && !WIN_UI
+ private readonly Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestTelemetryDataCollector? _telemetryDataCollector;
+#endif
+#if !WINDOWS_UWP && !WIN_UI
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The reflected type.
+ /// The name of the assembly being reflected.
+ /// An instance to reflection helper for type information.
+ /// The validator for test classes.
+ /// The validator for test methods.
+ /// Optional telemetry data collector for tracking API usage.
+ internal TypeEnumerator(Type type, string assemblyFilePath, ReflectHelper reflectHelper, TypeValidator typeValidator, TestMethodValidator testMethodValidator, Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.MSTestTelemetryDataCollector? telemetryDataCollector = null)
+#else
///
/// Initializes a new instance of the class.
///
@@ -29,12 +44,16 @@ internal class TypeEnumerator
/// The validator for test classes.
/// The validator for test methods.
internal TypeEnumerator(Type type, string assemblyFilePath, ReflectHelper reflectHelper, TypeValidator typeValidator, TestMethodValidator testMethodValidator)
+#endif
{
_type = type;
_assemblyFilePath = assemblyFilePath;
_reflectHelper = reflectHelper;
_typeValidator = typeValidator;
_testMethodValidator = testMethodValidator;
+#if !WINDOWS_UWP && !WIN_UI
+ _telemetryDataCollector = telemetryDataCollector;
+#endif
}
///
@@ -49,6 +68,15 @@ internal TypeEnumerator(Type type, string assemblyFilePath, ReflectHelper reflec
return null;
}
+ // Track class-level attributes for telemetry
+#if !WINDOWS_UWP && !WIN_UI
+ if (_telemetryDataCollector is not null)
+ {
+ Attribute[] classAttributes = _reflectHelper.GetCustomAttributesCached(_type);
+ _telemetryDataCollector.TrackDiscoveredClass(classAttributes);
+ }
+#endif
+
// If test class is valid, then get the tests
return GetTests(warnings);
}
@@ -143,6 +171,9 @@ internal UnitTestElement GetTestFromMethod(MethodInfo method, bool classDisables
};
Attribute[] attributes = _reflectHelper.GetCustomAttributesCached(method);
+#if !WINDOWS_UWP && !WIN_UI
+ _telemetryDataCollector?.TrackDiscoveredMethod(attributes);
+#endif
TestMethodAttribute? testMethodAttribute = null;
// Backward looping for backcompat. This used to be calls to _reflectHelper.GetFirstAttributeOrDefault
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.cs b/src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.cs
index 501d5559cd..d1d642a70f 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.cs
@@ -295,6 +295,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
}
///
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestTelemetryDataCollector.cs b/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestTelemetryDataCollector.cs
new file mode 100644
index 0000000000..8bab8353ff
--- /dev/null
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestTelemetryDataCollector.cs
@@ -0,0 +1,375 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+#if !WINDOWS_UWP && !WIN_UI
+using System.Security.Cryptography;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
+
+///
+/// Collects and aggregates telemetry data about MSTest usage within a test session.
+/// Captures settings, attribute usage, custom/inherited types, and assertion API usage.
+///
+internal sealed class MSTestTelemetryDataCollector
+{
+ private readonly Dictionary _attributeCounts = [];
+ private readonly HashSet _customTestMethodTypes = [];
+ private readonly HashSet _customTestClassTypes = [];
+
+#pragma warning disable IDE0032 // Use auto property - Volatile.Read/Write requires a ref to a field
+ private static MSTestTelemetryDataCollector? s_current;
+#pragma warning restore IDE0032 // Use auto property
+
+ ///
+ /// Gets or sets the current telemetry data collector for the session.
+ /// Set at session start, cleared at session close.
+ ///
+ internal static MSTestTelemetryDataCollector? Current
+ {
+ get => Volatile.Read(ref s_current);
+ set => Volatile.Write(ref s_current, value);
+ }
+
+ internal static MSTestTelemetryDataCollector EnsureInitialized()
+ {
+ MSTestTelemetryDataCollector? collector = Current;
+ if (collector is not null)
+ {
+ return collector;
+ }
+
+ collector = new MSTestTelemetryDataCollector();
+ MSTestTelemetryDataCollector? existingCollector = Interlocked.CompareExchange(ref s_current, collector, null);
+
+ return existingCollector ?? collector;
+ }
+
+ ///
+ /// Gets a value indicating whether any data has been collected.
+ ///
+ internal bool HasData { get; private set; }
+
+ ///
+ /// Checks whether telemetry collection is opted out via environment variables.
+ /// Mirrors the same checks as Microsoft.Testing.Platform's TelemetryManager.
+ ///
+ /// true if telemetry is opted out; false otherwise.
+ internal static bool IsTelemetryOptedOut()
+ {
+ string? telemetryOptOut = Environment.GetEnvironmentVariable("TESTINGPLATFORM_TELEMETRY_OPTOUT");
+ if (telemetryOptOut is "1" or "true")
+ {
+ return true;
+ }
+
+ string? cliTelemetryOptOut = Environment.GetEnvironmentVariable("DOTNET_CLI_TELEMETRY_OPTOUT");
+
+ return cliTelemetryOptOut is "1" or "true";
+ }
+
+ ///
+ /// Gets or sets the configuration source used for this session.
+ ///
+ internal string? ConfigurationSource { get; set; }
+
+ ///
+ /// Records the attributes found on a test method during discovery.
+ ///
+ /// The cached attributes from the method.
+ internal void TrackDiscoveredMethod(Attribute[] attributes)
+ {
+ HasData = true;
+
+ foreach (Attribute attribute in attributes)
+ {
+ Type attributeType = attribute.GetType();
+ string attributeName = attributeType.Name;
+
+ // Track custom/inherited TestMethodAttribute types (store anonymized hash)
+ if (attribute is TestMethodAttribute && attributeType != typeof(TestMethodAttribute))
+ {
+ _customTestMethodTypes.Add(AnonymizeString(attributeType.FullName ?? attributeName));
+ }
+
+ // Track custom/inherited TestClassAttribute types (store anonymized hash)
+ if (attribute is TestClassAttribute && attributeType != typeof(TestClassAttribute))
+ {
+ _customTestClassTypes.Add(AnonymizeString(attributeType.FullName ?? attributeName));
+ }
+
+ // Track attribute usage counts by base type name
+ string trackingName = attribute switch
+ {
+ TestMethodAttribute => nameof(TestMethodAttribute),
+ TestClassAttribute => nameof(TestClassAttribute),
+ DataRowAttribute => nameof(DataRowAttribute),
+ DynamicDataAttribute => nameof(DynamicDataAttribute),
+ TimeoutAttribute => nameof(TimeoutAttribute),
+ IgnoreAttribute => nameof(IgnoreAttribute),
+ DoNotParallelizeAttribute => nameof(DoNotParallelizeAttribute),
+ RetryBaseAttribute => nameof(RetryBaseAttribute),
+ ConditionBaseAttribute => nameof(ConditionBaseAttribute),
+ TestCategoryAttribute => nameof(TestCategoryAttribute),
+#if !WIN_UI
+ DeploymentItemAttribute => nameof(DeploymentItemAttribute),
+#endif
+ _ => attributeName,
+ };
+
+ _attributeCounts[trackingName] = _attributeCounts.TryGetValue(trackingName, out long count)
+ ? count + 1
+ : 1;
+ }
+ }
+
+ ///
+ /// Records the attributes found on a test class during discovery.
+ ///
+ /// The cached attributes from the class.
+ internal void TrackDiscoveredClass(Attribute[] attributes)
+ {
+ HasData = true;
+
+ foreach (Attribute attribute in attributes)
+ {
+ Type attributeType = attribute.GetType();
+
+ // Track custom/inherited TestClassAttribute types (store anonymized hash)
+ if (attribute is TestClassAttribute && attributeType != typeof(TestClassAttribute))
+ {
+ _customTestClassTypes.Add(AnonymizeString(attributeType.FullName ?? attributeType.Name));
+ }
+
+ string? trackingName = attribute switch
+ {
+ TestClassAttribute => nameof(TestClassAttribute),
+ ParallelizeAttribute => nameof(ParallelizeAttribute),
+ DoNotParallelizeAttribute => nameof(DoNotParallelizeAttribute),
+ _ => null,
+ };
+
+ if (trackingName is not null)
+ {
+ _attributeCounts[trackingName] = _attributeCounts.TryGetValue(trackingName, out long count)
+ ? count + 1
+ : 1;
+ }
+ }
+ }
+
+ ///
+ /// Builds the telemetry metrics dictionary for sending via the telemetry collector.
+ ///
+ /// A dictionary of telemetry key-value pairs.
+ internal Dictionary BuildMetrics()
+ {
+ Dictionary metrics = [];
+
+ // Settings
+ AddSettingsMetrics(metrics);
+
+ // Configuration source (runsettings, testconfig.json, or none)
+ if (ConfigurationSource is not null)
+ {
+ metrics["mstest.config_source"] = ConfigurationSource;
+ }
+
+ // Attribute usage (aggregated counts as JSON)
+ if (_attributeCounts.Count > 0)
+ {
+ metrics["mstest.attribute_usage"] = SerializeDictionary(_attributeCounts);
+ }
+
+ // Custom/inherited types (anonymized names)
+ if (_customTestMethodTypes.Count > 0)
+ {
+ metrics["mstest.custom_test_method_types"] = SerializeCollection(_customTestMethodTypes);
+ }
+
+ if (_customTestClassTypes.Count > 0)
+ {
+ metrics["mstest.custom_test_class_types"] = SerializeCollection(_customTestClassTypes);
+ }
+
+ // Assertion usage (drain the static counters)
+ Dictionary assertionCounts = TelemetryCollector.DrainAssertionCallCounts();
+ if (assertionCounts.Count > 0)
+ {
+ metrics["mstest.assertion_usage"] = SerializeDictionary(assertionCounts);
+ }
+
+ return metrics;
+ }
+
+ private static string SerializeCollection(IEnumerable values)
+ {
+ System.Text.StringBuilder builder = new("[");
+ bool isFirst = true;
+
+ foreach (string value in values)
+ {
+ if (!isFirst)
+ {
+ builder.Append(',');
+ }
+
+ AppendJsonString(builder, value);
+ isFirst = false;
+ }
+
+ builder.Append(']');
+ return builder.ToString();
+ }
+
+ private static string SerializeDictionary(Dictionary values)
+ {
+ System.Text.StringBuilder builder = new("{");
+ bool isFirst = true;
+
+ foreach (KeyValuePair value in values)
+ {
+ if (!isFirst)
+ {
+ builder.Append(',');
+ }
+
+ AppendJsonString(builder, value.Key);
+ builder.Append(':');
+ builder.Append(value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ isFirst = false;
+ }
+
+ builder.Append('}');
+ return builder.ToString();
+ }
+
+ private static void AppendJsonString(System.Text.StringBuilder builder, string value)
+ {
+ builder.Append('"');
+
+ foreach (char character in value)
+ {
+ switch (character)
+ {
+ case '"':
+ builder.Append("\\\"");
+ break;
+ case '\\':
+ builder.Append("\\\\");
+ break;
+ case '\b':
+ builder.Append("\\b");
+ break;
+ case '\f':
+ builder.Append("\\f");
+ break;
+ case '\n':
+ builder.Append("\\n");
+ break;
+ case '\r':
+ builder.Append("\\r");
+ break;
+ case '\t':
+ builder.Append("\\t");
+ break;
+ default:
+ if (char.IsControl(character))
+ {
+ builder.Append("\\u");
+ builder.Append(((int)character).ToString("x4", System.Globalization.CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ builder.Append(character);
+ }
+
+ break;
+ }
+ }
+
+ builder.Append('"');
+ }
+
+ private static void AddSettingsMetrics(Dictionary metrics)
+ {
+ MSTestSettings settings = MSTestSettings.CurrentSettings;
+
+ // Parallelization
+ metrics["mstest.setting.parallelization_enabled"] = !settings.DisableParallelization;
+ if (settings.ParallelizationScope is not null)
+ {
+ metrics["mstest.setting.parallelization_scope"] = settings.ParallelizationScope.Value.ToString();
+ }
+
+ if (settings.ParallelizationWorkers is not null)
+ {
+ metrics["mstest.setting.parallelization_workers"] = settings.ParallelizationWorkers.Value;
+ }
+
+ // Timeouts
+ metrics["mstest.setting.test_timeout"] = settings.TestTimeout;
+ metrics["mstest.setting.assembly_initialize_timeout"] = settings.AssemblyInitializeTimeout;
+ metrics["mstest.setting.assembly_cleanup_timeout"] = settings.AssemblyCleanupTimeout;
+ metrics["mstest.setting.class_initialize_timeout"] = settings.ClassInitializeTimeout;
+ metrics["mstest.setting.class_cleanup_timeout"] = settings.ClassCleanupTimeout;
+ metrics["mstest.setting.test_initialize_timeout"] = settings.TestInitializeTimeout;
+ metrics["mstest.setting.test_cleanup_timeout"] = settings.TestCleanupTimeout;
+ metrics["mstest.setting.cooperative_cancellation"] = settings.CooperativeCancellationTimeout;
+
+ // Behavior
+ metrics["mstest.setting.map_inconclusive_to_failed"] = settings.MapInconclusiveToFailed;
+ metrics["mstest.setting.map_not_runnable_to_failed"] = settings.MapNotRunnableToFailed;
+ metrics["mstest.setting.treat_discovery_warnings_as_errors"] = settings.TreatDiscoveryWarningsAsErrors;
+ metrics["mstest.setting.consider_empty_data_source_as_inconclusive"] = settings.ConsiderEmptyDataSourceAsInconclusive;
+ metrics["mstest.setting.order_tests_by_name"] = settings.OrderTestsByNameInClass;
+ metrics["mstest.setting.capture_debug_traces"] = settings.CaptureDebugTraces;
+ metrics["mstest.setting.has_test_settings_file"] = settings.TestSettingsFile is not null;
+ }
+
+ private static string AnonymizeString(string value)
+ {
+#if NET
+ byte[] hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value));
+ return Convert.ToHexString(hash);
+#else
+ using var sha256 = SHA256.Create();
+ byte[] hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(value));
+ return BitConverter.ToString(hash).Replace("-", string.Empty);
+#endif
+ }
+
+ ///
+ /// Sends collected telemetry via the provided sender delegate and resets the current collector.
+ /// Safe to call even when no sender is available (no-op).
+ ///
+ /// Optional delegate to send telemetry. If null, telemetry is silently discarded.
+ internal static async Task SendTelemetryAndResetAsync(Func, Task>? telemetrySender)
+ {
+ try
+ {
+ MSTestTelemetryDataCollector? collector = Current;
+ if (collector is not { HasData: true } || telemetrySender is null)
+ {
+ TelemetryCollector.DrainAssertionCallCounts();
+ return;
+ }
+
+ Dictionary metrics = collector.BuildMetrics();
+ if (metrics.Count > 0)
+ {
+ await telemetrySender("dotnet/testingplatform/mstest/sessionexit", metrics).ConfigureAwait(false);
+ }
+ }
+ catch (Exception)
+ {
+ // Telemetry should never cause test failures
+ }
+ finally
+ {
+ Current = null;
+ }
+ }
+}
+#endif
diff --git a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj
index 0c69386a00..869e113dcf 100644
--- a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj
+++ b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj
@@ -59,6 +59,7 @@ This package provides the core platform and the .NET implementation of the proto
+
diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs
index 5cd7fe9939..2938a04074 100644
--- a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs
+++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs
@@ -48,6 +48,7 @@ internal void ComputeAssertion(string expectedExpression, string actualExpressio
{
if (_builder is not null)
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
_builder.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "expected", expectedExpression, "actual", actualExpression) + " ");
ThrowAssertAreEqualFailed(_expected, _actual, _builder.ToString());
}
@@ -115,6 +116,7 @@ internal void ComputeAssertion(string notExpectedExpression, string actualExpres
{
if (_builder is not null)
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
_builder.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "notExpected", notExpectedExpression, "actual", actualExpression) + " ");
ThrowAssertAreNotEqualFailed(_notExpected, _actual, _builder.ToString());
}
@@ -221,6 +223,7 @@ internal void ComputeAssertion(string expectedExpression, string actualExpressio
{
if (_failAction is not null)
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
_builder!.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "expected", expectedExpression, "actual", actualExpression) + " ");
_failAction.Invoke(_builder!.ToString());
}
@@ -327,6 +330,7 @@ internal void ComputeAssertion(string notExpectedExpression, string actualExpres
{
if (_failAction is not null)
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
_builder!.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "notExpected", notExpectedExpression, "actual", actualExpression) + " ");
_failAction.Invoke(_builder!.ToString());
}
@@ -483,6 +487,8 @@ public static void AreEqual(T? expected, T? actual, IEqualityComparer? com
///
public static void AreEqual(T? expected, T? actual, IEqualityComparer comparer, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+
if (!AreEqualFailing(expected, actual, comparer))
{
return;
@@ -786,6 +792,8 @@ public static void AreNotEqual(T? notExpected, T? actual, IEqualityComparer
public static void AreNotEqual(T? notExpected, T? actual, IEqualityComparer comparer, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+
if (!AreNotEqualFailing(notExpected, actual, comparer))
{
return;
@@ -835,6 +843,8 @@ public static void AreEqual(float expected, float actual, float delta, [Interpol
///
public static void AreEqual(float expected, float actual, float delta, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+
if (AreEqualFailing(expected, actual, delta))
{
string userMessage = BuildUserMessageForExpectedExpressionAndActualExpression(message, expectedExpression, actualExpression);
@@ -882,6 +892,8 @@ public static void AreNotEqual(float notExpected, float actual, float delta, [In
///
public static void AreNotEqual(float notExpected, float actual, float delta, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+
if (AreNotEqualFailing(notExpected, actual, delta))
{
string userMessage = BuildUserMessageForNotExpectedExpressionAndActualExpression(message, notExpectedExpression, actualExpression);
@@ -950,6 +962,8 @@ public static void AreEqual(decimal expected, decimal actual, decimal delta, [In
///
public static void AreEqual(decimal expected, decimal actual, decimal delta, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+
if (AreEqualFailing(expected, actual, delta))
{
string userMessage = BuildUserMessageForExpectedExpressionAndActualExpression(message, expectedExpression, actualExpression);
@@ -997,6 +1011,8 @@ public static void AreNotEqual(decimal notExpected, decimal actual, decimal delt
///
public static void AreNotEqual(decimal notExpected, decimal actual, decimal delta, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+
if (AreNotEqualFailing(notExpected, actual, delta))
{
string userMessage = BuildUserMessageForNotExpectedExpressionAndActualExpression(message, notExpectedExpression, actualExpression);
@@ -1047,6 +1063,8 @@ public static void AreEqual(long expected, long actual, long delta, [Interpolate
///
public static void AreEqual(long expected, long actual, long delta, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+
if (AreEqualFailing(expected, actual, delta))
{
string userMessage = BuildUserMessageForExpectedExpressionAndActualExpression(message, expectedExpression, actualExpression);
@@ -1094,6 +1112,8 @@ public static void AreNotEqual(long notExpected, long actual, long delta, [Inter
///
public static void AreNotEqual(long notExpected, long actual, long delta, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+
if (AreNotEqualFailing(notExpected, actual, delta))
{
string userMessage = BuildUserMessageForNotExpectedExpressionAndActualExpression(message, notExpectedExpression, actualExpression);
@@ -1143,6 +1163,8 @@ public static void AreEqual(double expected, double actual, double delta, [Inter
///
public static void AreEqual(double expected, double actual, double delta, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+
if (AreEqualFailing(expected, actual, delta))
{
string userMessage = BuildUserMessageForExpectedExpressionAndActualExpression(message, expectedExpression, actualExpression);
@@ -1190,6 +1212,8 @@ public static void AreNotEqual(double notExpected, double actual, double delta,
///
public static void AreNotEqual(double notExpected, double actual, double delta, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+
if (AreNotEqualFailing(notExpected, actual, delta))
{
string userMessage = BuildUserMessageForNotExpectedExpressionAndActualExpression(message, notExpectedExpression, actualExpression);
@@ -1316,6 +1340,8 @@ public static void AreEqual(string? expected, string? actual, bool ignoreCase,
///
public static void AreEqual(string? expected, string? actual, bool ignoreCase, CultureInfo culture, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+
CheckParameterNotNull(culture, "Assert.AreEqual", "culture");
if (!AreEqualFailing(expected, actual, ignoreCase, culture))
{
@@ -1412,6 +1438,8 @@ public static void AreNotEqual(string? notExpected, string? actual, bool ignoreC
///
public static void AreNotEqual(string? notExpected, string? actual, bool ignoreCase, CultureInfo culture, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+
CheckParameterNotNull(culture, "Assert.AreNotEqual", "culture");
if (!AreNotEqualFailing(notExpected, actual, ignoreCase, culture))
{
diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs
index 93be91c7fd..90491fec73 100644
--- a/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs
+++ b/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs
@@ -38,6 +38,7 @@ internal void ComputeAssertion(string expectedExpression, string actualExpressio
{
if (_builder is not null)
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreSame");
_builder.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "expected", expectedExpression, "actual", actualExpression) + " ");
ThrowAssertAreSameFailed(_expected, _actual, _builder.ToString());
}
@@ -94,6 +95,7 @@ internal void ComputeAssertion(string notExpectedExpression, string actualExpres
{
if (_builder is not null)
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotSame");
_builder.Insert(0, string.Format(CultureInfo.CurrentCulture, FrameworkMessages.CallerArgumentExpressionTwoParametersMessage, "notExpected", notExpectedExpression, "actual", actualExpression) + " ");
ThrowAssertAreNotSameFailed(_builder.ToString());
}
@@ -172,6 +174,8 @@ public static void AreSame(T? expected, T? actual, [InterpolatedStringHandler
///
public static void AreSame(T? expected, T? actual, string? message = "", [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreSame");
+
if (!IsAreSameFailing(expected, actual))
{
return;
@@ -238,6 +242,8 @@ public static void AreNotSame(T? notExpected, T? actual, [InterpolatedStringH
///
public static void AreNotSame(T? notExpected, T? actual, string? message = "", [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotSame");
+
if (IsAreNotSameFailing(notExpected, actual))
{
ThrowAssertAreNotSameFailed(BuildUserMessageForNotExpectedExpressionAndActualExpression(message, notExpectedExpression, actualExpression));
diff --git a/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs b/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs
index 19fb68e0a6..01b90d7b93 100644
--- a/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs
+++ b/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs
@@ -154,6 +154,8 @@ public static T ContainsSingle(IEnumerable collection, string? message = "
/// The item that matches the predicate.
public static T ContainsSingle(Func predicate, IEnumerable collection, string? message = "", [CallerArgumentExpression(nameof(predicate))] string predicateExpression = "", [CallerArgumentExpression(nameof(collection))] string collectionExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.ContainsSingle");
+
T firstMatch = default!;
int matchCount = 0;
@@ -209,6 +211,8 @@ public static T ContainsSingle(Func predicate, IEnumerable collec
/// The item that matches the predicate.
public static object? ContainsSingle(Func