diff --git a/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj b/src/Adapter/MSTest.TestAdapter/MSTest.TestAdapter.csproj
index 5d44f04283..9d6f20686e 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 f15fd04dda..4fb87cc30e 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,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);
}
///
@@ -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);
}
@@ -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, 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 5b46c15750..a98e00c83a 100644
--- a/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestDiscoverer.cs
+++ b/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestDiscoverer.cs
@@ -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, 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()
: 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.
@@ -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 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();
- internal void DiscoverTests(IEnumerable sources, IDiscoveryContext discoveryContext, IMessageLogger logger, ITestCaseDiscoverySink discoverySink, IConfiguration? configuration, bool isMTP)
+ internal async Task DiscoverTestsAsync(IEnumerable sources, IDiscoveryContext discoveryContext, IMessageLogger logger, ITestCaseDiscoverySink discoverySink, IConfiguration? configuration, bool isMTP)
{
if (sources is null)
{
@@ -58,9 +74,31 @@ internal void DiscoverTests(IEnumerable 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
}
}
}
diff --git a/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestExecutor.cs b/src/Adapter/MSTest.TestAdapter/VSTestAdapter/MSTestExecutor.cs
index 903d9f9313..f85044c5f8 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
}
///
@@ -119,12 +127,27 @@ internal async Task RunTestsAsync(IEnumerable? tests, IRunContext? run
Ensure.NotEmpty(tests);
- 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? sources, IRunContext? runContext, IFrameworkHandle? frameworkHandle, IConfiguration? configuration, bool isMTP)
@@ -147,14 +170,29 @@ internal async Task RunTestsAsync(IEnumerable? 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);
+ }
}
///
@@ -163,6 +201,14 @@ internal async Task RunTestsAsync(IEnumerable? 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 runTestsAction)
{
ApartmentState? requestedApartmentState = MSTestSettings.RunConfigurationSettings.ExecutionApartmentState;
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs b/src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs
index 1aefac9304..60df656e33 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs
@@ -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);
}
@@ -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? workItemIds = null;
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.Configuration.cs b/src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.Configuration.cs
index f200893c1b..01aa0e45e8 100644
--- a/src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.Configuration.cs
+++ b/src/Adapter/MSTestAdapter.PlatformServices/MSTestSettings.Configuration.cs
@@ -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)
diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestTelemetryDataCollector.cs b/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestTelemetryDataCollector.cs
new file mode 100644
index 0000000000..af1c732475
--- /dev/null
+++ b/src/Adapter/MSTestAdapter.PlatformServices/Telemetry/MSTestTelemetryDataCollector.cs
@@ -0,0 +1,516 @@
+// 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.
+///
+///
+/// This collector relies on static state () that lives in the
+/// AppDomain in which the adapter executes. On .NET Framework runs that opt into the
+/// adapter's child-AppDomain isolation, code that runs inside the child AppDomain (for
+/// example, attribute discovery via the adapter's enumerators) sees its own
+/// snapshot, which is initially null. In that case telemetry from
+/// the isolated AppDomain is silently dropped (the Current?.Track* call sites are
+/// null-safe). This is an intentional, graceful degradation: the .NET Framework AppDomain
+/// scenario is rare and the effort to marshal counters across AppDomain boundaries via
+/// is not justified for best-effort usage telemetry.
+///
+internal sealed class MSTestTelemetryDataCollector
+{
+ private readonly ConcurrentDictionary _attributeCounts = new();
+#if NET9_0_OR_GREATER
+ private readonly Lock _customTypesGate = new();
+#else
+ private readonly object _customTypesGate = new();
+#endif
+ 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;
+ private static int s_discoveryEventEmitted;
+
+ // Volatile because ConfigurationSource is written from the discovery thread (e.g. settings
+ // load) and read from whichever thread runs SendDiscoveryTelemetryAndResetAsync — without
+ // a memory barrier the reader could in principle observe a stale null.
+ private volatile string? _configurationSource;
+#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;
+ }
+
+ ///
+ /// 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 => _configurationSource;
+ set => _configurationSource = value;
+ }
+
+ ///
+ /// Records the attributes found on a test method during discovery. Safe to call concurrently
+ /// from multiple discovery threads — counters use a
+ /// and the custom-type sets are protected by an internal lock.
+ ///
+ /// The cached attributes from the method.
+ internal void TrackDiscoveredMethod(Attribute[] attributes)
+ {
+ 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))
+ {
+ AddCustomType(_customTestMethodTypes, AnonymizeString(attributeType.FullName ?? attributeName));
+ }
+
+ // Track custom/inherited TestClassAttribute types (store anonymized hash)
+ if (attribute is TestClassAttribute && attributeType != typeof(TestClassAttribute))
+ {
+ AddCustomType(_customTestClassTypes, AnonymizeString(attributeType.FullName ?? attributeName));
+ }
+
+ // Track attribute usage counts by base type name (only known MSTest attributes)
+ 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
+ _ => null,
+ };
+
+ if (trackingName is not null)
+ {
+ _attributeCounts.AddOrUpdate(trackingName, 1, static (_, count) => count + 1);
+ }
+ }
+ }
+
+ ///
+ /// Records the attributes found on a test class during discovery. Safe to call concurrently
+ /// from multiple discovery threads — counters use a
+ /// and the custom-type sets are protected by an internal lock.
+ ///
+ /// The cached attributes from the class.
+ internal void TrackDiscoveredClass(Attribute[] attributes)
+ {
+ foreach (Attribute attribute in attributes)
+ {
+ Type attributeType = attribute.GetType();
+
+ // Track custom/inherited TestClassAttribute types (store anonymized hash)
+ if (attribute is TestClassAttribute && attributeType != typeof(TestClassAttribute))
+ {
+ AddCustomType(_customTestClassTypes, 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.AddOrUpdate(trackingName, 1, static (_, count) => count + 1);
+ }
+ }
+ }
+
+ private void AddCustomType(HashSet set, string value)
+ {
+ lock (_customTypesGate)
+ {
+ set.Add(value);
+ }
+ }
+
+ ///
+ /// Builds the discovery telemetry metrics dictionary (settings + discovery-time data).
+ /// Sent at the end of MTP discover-only sessions (e.g. dotnet test --list-tests).
+ ///
+ /// A dictionary of telemetry key-value pairs for the discovery event.
+ internal Dictionary BuildDiscoveryMetrics()
+ {
+ Dictionary metrics = [];
+ AddDiscoveryMetrics(metrics);
+ return metrics;
+ }
+
+ ///
+ /// Builds the execution telemetry metrics dictionary. Sent at the end of an MSTest run
+ /// (MTP run mode or VSTest run mode). Always carries assertion usage. Also carries the
+ /// settings/attribute/config payload UNLESS a discovery event has already been emitted
+ /// during this process — that avoids duplicating the discovery payload across two events
+ /// when a host (such as a future MTP host) chooses to call both discover and run within
+ /// the same session.
+ ///
+ /// Drained assertion call counts captured during execution.
+ /// When true, also include the discovery metrics
+ /// (settings, config_source, attribute_usage, custom_test_*_types). False when the discovery
+ /// event already shipped these in this process.
+ /// A dictionary of telemetry key-value pairs for the sessionexit event.
+ internal Dictionary BuildExecutionMetrics(Dictionary assertionCounts, bool includeDiscoveryPayload)
+ {
+ Dictionary metrics = [];
+
+ if (includeDiscoveryPayload)
+ {
+ AddDiscoveryMetrics(metrics);
+ }
+
+ if (assertionCounts.Count > 0)
+ {
+ metrics["mstest.assertion_usage"] = SerializeDictionary(assertionCounts);
+ }
+
+ return metrics;
+ }
+
+ private void AddDiscoveryMetrics(Dictionary metrics)
+ {
+ // Settings
+ AddSettingsMetrics(metrics);
+
+ // Configuration source (runsettings, testconfig.json, or none)
+ if (ConfigurationSource is { } configSource)
+ {
+ metrics["mstest.config_source"] = configSource;
+ }
+
+ // Attribute usage (aggregated counts as JSON; serializer enforces ordinal sort)
+ if (!_attributeCounts.IsEmpty)
+ {
+ metrics["mstest.attribute_usage"] = SerializeDictionary(_attributeCounts);
+ }
+
+ // Custom/inherited types (anonymized names; serializer enforces ordinal sort)
+ // Take a snapshot under the lock that protects the HashSet to avoid concurrent
+ // modification while serializing.
+ string[]? customMethodTypesSnapshot = SnapshotCustomTypes(_customTestMethodTypes);
+ if (customMethodTypesSnapshot is { Length: > 0 })
+ {
+ metrics["mstest.custom_test_method_types"] = SerializeCollection(customMethodTypesSnapshot);
+ }
+
+ string[]? customClassTypesSnapshot = SnapshotCustomTypes(_customTestClassTypes);
+ if (customClassTypesSnapshot is { Length: > 0 })
+ {
+ metrics["mstest.custom_test_class_types"] = SerializeCollection(customClassTypesSnapshot);
+ }
+ }
+
+ private string[]? SnapshotCustomTypes(HashSet set)
+ {
+ lock (_customTypesGate)
+ {
+ return set.Count == 0 ? null : [.. set];
+ }
+ }
+
+ private static string SerializeCollection(IEnumerable values)
+ {
+ StringBuilder builder = new("[");
+ bool isFirst = true;
+
+ foreach (string value in values.OrderBy(static x => x, StringComparer.Ordinal))
+ {
+ if (!isFirst)
+ {
+ builder.Append(',');
+ }
+
+ AppendJsonString(builder, value);
+ isFirst = false;
+ }
+
+ builder.Append(']');
+ return builder.ToString();
+ }
+
+ private static string SerializeDictionary(IEnumerable> values)
+ {
+ StringBuilder builder = new("{");
+ bool isFirst = true;
+
+ foreach (KeyValuePair value in values.OrderBy(x => x.Key, StringComparer.Ordinal))
+ {
+ if (!isFirst)
+ {
+ builder.Append(',');
+ }
+
+ AppendJsonString(builder, value.Key);
+ builder.Append(':');
+ builder.Append(value.Value.ToString(CultureInfo.InvariantCulture));
+ isFirst = false;
+ }
+
+ builder.Append('}');
+ return builder.ToString();
+ }
+
+ private static void AppendJsonString(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", 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"] = AsTelemetryBool(!settings.DisableParallelization);
+ if (settings.ParallelizationScope is not null)
+ {
+ metrics["mstest.setting.parallelization_scope"] = settings.ParallelizationScope.Value.ToString();
+ }
+
+ if (settings.ParallelizationWorkers is not null)
+ {
+ // Cast to double so AppInsightsProvider routes this through the metric channel
+ // instead of stringifying it as a property — see AppInsightsProvider.SendLoopAsync.
+ metrics["mstest.setting.parallelization_workers"] = (double)settings.ParallelizationWorkers.Value;
+ }
+
+ // Timeouts (cast to double for the same reason as parallelization_workers above).
+ metrics["mstest.setting.test_timeout"] = (double)settings.TestTimeout;
+ metrics["mstest.setting.assembly_initialize_timeout"] = (double)settings.AssemblyInitializeTimeout;
+ metrics["mstest.setting.assembly_cleanup_timeout"] = (double)settings.AssemblyCleanupTimeout;
+ metrics["mstest.setting.class_initialize_timeout"] = (double)settings.ClassInitializeTimeout;
+ metrics["mstest.setting.class_cleanup_timeout"] = (double)settings.ClassCleanupTimeout;
+ metrics["mstest.setting.test_initialize_timeout"] = (double)settings.TestInitializeTimeout;
+ metrics["mstest.setting.test_cleanup_timeout"] = (double)settings.TestCleanupTimeout;
+ metrics["mstest.setting.cooperative_cancellation"] = AsTelemetryBool(settings.CooperativeCancellationTimeout);
+
+ // Behavior
+ metrics["mstest.setting.map_inconclusive_to_failed"] = AsTelemetryBool(settings.MapInconclusiveToFailed);
+ metrics["mstest.setting.map_not_runnable_to_failed"] = AsTelemetryBool(settings.MapNotRunnableToFailed);
+ metrics["mstest.setting.treat_discovery_warnings_as_errors"] = AsTelemetryBool(settings.TreatDiscoveryWarningsAsErrors);
+ metrics["mstest.setting.consider_empty_data_source_as_inconclusive"] = AsTelemetryBool(settings.ConsiderEmptyDataSourceAsInconclusive);
+ metrics["mstest.setting.order_tests_by_name"] = AsTelemetryBool(settings.OrderTestsByNameInClass);
+ metrics["mstest.setting.capture_debug_traces"] = AsTelemetryBool(settings.CaptureDebugTraces);
+ }
+
+ // MTP's telemetry providers (e.g. AppInsightsProvider) reject raw boolean values and assert
+ // that they should be sent as their lowercase string form. This mirrors
+ // Microsoft.Testing.Platform.Telemetry.TelemetryExtensions.AsTelemetryBool, which we can't
+ // reference from here because that type lives in a different assembly.
+ private static string AsTelemetryBool(bool value) => value ? "true" : "false";
+
+ 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 the accumulated discovery telemetry via the provided sender delegate and clears the
+ /// discovery state by resetting the current collector. Safe to call when no sender is available
+ /// (the collector is still cleared so state does not leak across sessions).
+ ///
+ /// Optional delegate to send telemetry. If null, telemetry is silently discarded.
+ internal static async Task SendDiscoveryTelemetryAndResetAsync(Func, Task>? telemetrySender)
+ {
+ try
+ {
+ MSTestTelemetryDataCollector? collector = Current;
+ if (collector is null || telemetrySender is null)
+ {
+ return;
+ }
+
+ // Defense in depth: re-check opt-out at send time in case the env var was set after
+ // EnsureInitialized but before this point.
+ if (IsTelemetryOptedOut())
+ {
+ return;
+ }
+
+ Dictionary metrics = collector.BuildDiscoveryMetrics();
+ if (metrics.Count > 0)
+ {
+ await telemetrySender("dotnet/testingplatform/mstest/discovery", metrics).ConfigureAwait(false);
+
+ // Mark that the discovery payload (settings + attribute_usage + custom_test_*_types
+ // + config_source) has shipped in this process so a subsequent execution event in
+ // the same session does not duplicate it.
+ Interlocked.Exchange(ref s_discoveryEventEmitted, 1);
+ }
+ }
+ catch (Exception)
+ {
+ // Telemetry should never cause test failures
+ }
+ finally
+ {
+ // Clear the current collector so a subsequent execution accumulates settings/config
+ // anew (settings are static-per-process so re-population is cheap and keeps each
+ // event self-contained).
+ Current = null;
+ }
+ }
+
+ ///
+ /// Sends the accumulated execution telemetry via the provided sender delegate, drains the
+ /// static assertion counters, and clears the current collector. Safe to call when no sender
+ /// is available (the counters and collector are still drained/cleared so state does not leak
+ /// across sessions).
+ ///
+ /// Optional delegate to send telemetry. If null, telemetry is silently discarded.
+ internal static async Task SendExecutionTelemetryAndResetAsync(Func, Task>? telemetrySender)
+ {
+ try
+ {
+ // Always drain the static assertion counters so they don't leak across sessions,
+ // even when no sender is wired (VSTest mode) or no collector was initialized
+ // (e.g. telemetry opted out before EnsureInitialized was called).
+ Dictionary assertionCounts = TelemetryCollector.DrainAssertionCallCounts();
+
+ MSTestTelemetryDataCollector? collector = Current;
+ if (collector is null || telemetrySender is null)
+ {
+ return;
+ }
+
+ // Defense in depth: re-check opt-out at send time in case the env var was set after
+ // EnsureInitialized but before this point.
+ if (IsTelemetryOptedOut())
+ {
+ return;
+ }
+
+ // If the discovery event already shipped the settings/attribute payload during this
+ // process, do not duplicate it in the sessionexit event. The flag is reset below in
+ // the finally block so each process can still ship a fresh payload after a full
+ // session reset.
+ bool includeDiscoveryPayload = Interlocked.CompareExchange(ref s_discoveryEventEmitted, 0, 0) == 0;
+
+ Dictionary metrics = collector.BuildExecutionMetrics(assertionCounts, includeDiscoveryPayload);
+ if (metrics.Count > 0)
+ {
+ await telemetrySender("dotnet/testingplatform/mstest/sessionexit", metrics).ConfigureAwait(false);
+ }
+ }
+ catch (Exception)
+ {
+ // Telemetry should never cause test failures
+ }
+ finally
+ {
+ Current = null;
+ Interlocked.Exchange(ref s_discoveryEventEmitted, 0);
+ }
+ }
+}
+#endif
diff --git a/src/Platform/Microsoft.Testing.Extensions.Telemetry/AppInsightsProvider.cs b/src/Platform/Microsoft.Testing.Extensions.Telemetry/AppInsightsProvider.cs
index 2b94ff37b7..4724dc2c02 100644
--- a/src/Platform/Microsoft.Testing.Extensions.Telemetry/AppInsightsProvider.cs
+++ b/src/Platform/Microsoft.Testing.Extensions.Telemetry/AppInsightsProvider.cs
@@ -63,7 +63,17 @@ internal sealed partial class AppInsightsProvider :
TelemetryProperties.HostProperties.RuntimeIdentifierPropertyName,
TelemetryProperties.HostProperties.ApplicationModePropertyName,
TelemetryProperties.HostProperties.ExitCodePropertyName,
- TelemetryProperties.HostProperties.ExtensionsPropertyName
+ TelemetryProperties.HostProperties.ExtensionsPropertyName,
+
+ // MSTest session telemetry (see MSTestTelemetryDataCollector). These carry aggregated
+ // counts, anonymized SHA256 type hashes inside JSON envelopes, and well-known enum/string
+ // values - none of them contain unhashed user-identifying data.
+ "mstest.config_source",
+ "mstest.attribute_usage",
+ "mstest.custom_test_method_types",
+ "mstest.custom_test_class_types",
+ "mstest.assertion_usage",
+ "mstest.setting.parallelization_scope",
];
#endif
diff --git a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj
index 376a5686ba..9aabda5ddc 100644
--- a/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj
+++ b/src/Platform/Microsoft.Testing.Platform/Microsoft.Testing.Platform.csproj
@@ -58,6 +58,7 @@ This package provides the core platform and the .NET implementation of the proto
+
diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.Numerics.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.Numerics.cs
index 95d917ae41..aa42fa6abf 100644
--- a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.Numerics.cs
+++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.Numerics.cs
@@ -81,7 +81,10 @@ private static void ReportAssertAreEqualFailed(T expected, T actual, T delta,
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreEqual(float expected, float actual, float delta, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual), nameof(delta))] ref AssertNonGenericAreEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(expectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+ message.ComputeAssertion(expectedExpression, actualExpression);
+ }
///
/// Tests whether the specified floats are equal and throws an exception
@@ -117,6 +120,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);
@@ -128,7 +133,10 @@ public static void AreEqual(float expected, float actual, float delta, string? m
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreNotEqual(float notExpected, float actual, float delta, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual), nameof(delta))] ref AssertNonGenericAreNotEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(notExpectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+ message.ComputeAssertion(notExpectedExpression, actualExpression);
+ }
///
/// Tests whether the specified floats are unequal and throws an exception
@@ -164,6 +172,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);
@@ -196,7 +206,10 @@ private static bool AreNotEqualFailing(float notExpected, float actual, float de
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreEqual(decimal expected, decimal actual, decimal delta, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual), nameof(delta))] ref AssertNonGenericAreEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(expectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+ message.ComputeAssertion(expectedExpression, actualExpression);
+ }
///
/// Tests whether the specified decimals are equal and throws an exception
@@ -232,6 +245,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);
@@ -243,7 +258,10 @@ public static void AreEqual(decimal expected, decimal actual, decimal delta, str
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreNotEqual(decimal notExpected, decimal actual, decimal delta, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual), nameof(delta))] ref AssertNonGenericAreNotEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(notExpectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+ message.ComputeAssertion(notExpectedExpression, actualExpression);
+ }
///
/// Tests whether the specified decimals are unequal and throws an exception
@@ -279,6 +297,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);
@@ -293,7 +313,10 @@ private static bool AreNotEqualFailing(decimal notExpected, decimal actual, deci
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreEqual(long expected, long actual, long delta, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual), nameof(delta))] ref AssertNonGenericAreEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(expectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+ message.ComputeAssertion(expectedExpression, actualExpression);
+ }
///
/// Tests whether the specified longs are equal and throws an exception
@@ -329,6 +352,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);
@@ -340,7 +365,10 @@ public static void AreEqual(long expected, long actual, long delta, string? mess
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreNotEqual(long notExpected, long actual, long delta, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual), nameof(delta))] ref AssertNonGenericAreNotEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(notExpectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+ message.ComputeAssertion(notExpectedExpression, actualExpression);
+ }
///
/// Tests whether the specified longs are unequal and throws an exception
@@ -376,6 +404,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);
@@ -390,7 +420,10 @@ private static bool AreNotEqualFailing(long notExpected, long actual, long delta
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreEqual(double expected, double actual, double delta, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual), nameof(delta))] ref AssertNonGenericAreEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(expectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+ message.ComputeAssertion(expectedExpression, actualExpression);
+ }
///
/// Tests whether the specified doubles are equal and throws an exception
@@ -425,6 +458,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);
@@ -436,7 +471,10 @@ public static void AreEqual(double expected, double actual, double delta, string
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreNotEqual(double notExpected, double actual, double delta, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual), nameof(delta))] ref AssertNonGenericAreNotEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(notExpectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+ message.ComputeAssertion(notExpectedExpression, actualExpression);
+ }
///
/// Tests whether the specified doubles are unequal and throws an exception
@@ -472,6 +510,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);
diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.String.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.String.cs
index aafc366e5c..74299f9e38 100644
--- a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.String.cs
+++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.String.cs
@@ -80,7 +80,10 @@ public static void AreEqual(string? expected, string? actual, bool ignoreCase, s
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreEqual(string? expected, string? actual, bool ignoreCase, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual), nameof(ignoreCase))] ref AssertNonGenericAreEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(expectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+ message.ComputeAssertion(expectedExpression, actualExpression);
+ }
///
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
@@ -88,6 +91,7 @@ public static void AreEqual(string? expected, string? actual, bool ignoreCase,
#pragma warning restore IDE0060 // Remove unused parameter
CultureInfo culture, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual), nameof(ignoreCase), nameof(culture))] ref AssertNonGenericAreEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
CheckParameterNotNull(culture, "Assert.AreEqual", nameof(culture));
message.ComputeAssertion(expectedExpression, actualExpression);
}
@@ -127,6 +131,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", nameof(culture));
if (!AreEqualFailing(expected, actual, ignoreCase, culture))
{
@@ -175,7 +181,10 @@ public static void AreNotEqual(string? notExpected, string? actual, bool ignoreC
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreNotEqual(string? notExpected, string? actual, bool ignoreCase, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual), nameof(ignoreCase))] ref AssertNonGenericAreNotEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(notExpectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+ message.ComputeAssertion(notExpectedExpression, actualExpression);
+ }
///
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
@@ -183,6 +192,7 @@ public static void AreNotEqual(string? notExpected, string? actual, bool ignoreC
#pragma warning restore IDE0060 // Remove unused parameter
CultureInfo culture, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual), nameof(ignoreCase), nameof(culture))] ref AssertNonGenericAreNotEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
{
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
CheckParameterNotNull(culture, "Assert.AreNotEqual", nameof(culture));
message.ComputeAssertion(notExpectedExpression, actualExpression);
}
@@ -223,6 +233,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.AreEqual.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs
index 3142d9644f..59710dcab7 100644
--- a/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs
+++ b/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs
@@ -81,13 +81,19 @@ public static void AreEqual(T? expected, T? actual, string? message = "", [Ca
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreEqual(T? expected, T? actual, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual))] ref AssertAreEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(expectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+ message.ComputeAssertion(expectedExpression, actualExpression);
+ }
///
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreEqual(T? expected, T? actual, IEqualityComparer? comparer, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual), nameof(comparer))] ref AssertAreEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(expectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreEqual");
+ message.ComputeAssertion(expectedExpression, actualExpression);
+ }
///
/// Tests whether the specified values are equal and throws an exception
@@ -126,6 +132,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;
@@ -297,13 +305,19 @@ public static void AreNotEqual(T? notExpected, T? actual, string? message = "
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreNotEqual(T? notExpected, T? actual, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual))] ref AssertAreNotEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(notExpectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+ message.ComputeAssertion(notExpectedExpression, actualExpression);
+ }
///
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreNotEqual(T? notExpected, T? actual, IEqualityComparer comparer, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual), nameof(comparer))] ref AssertAreNotEqualInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(notExpectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotEqual");
+ message.ComputeAssertion(notExpectedExpression, actualExpression);
+ }
///
/// Tests whether the specified values are unequal and throws an exception
@@ -342,6 +356,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;
diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs
index dced467094..2a3eaf72dc 100644
--- a/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs
+++ b/src/TestFramework/TestFramework/Assertions/Assert.AreSame.cs
@@ -138,7 +138,10 @@ internal void ComputeAssertion(string notExpectedExpression, string actualExpres
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreSame(T? expected, T? actual, [InterpolatedStringHandlerArgument(nameof(expected), nameof(actual))] ref AssertAreSameInterpolatedStringHandler message, [CallerArgumentExpression(nameof(expected))] string expectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(expectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreSame");
+ message.ComputeAssertion(expectedExpression, actualExpression);
+ }
#pragma warning disable RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads
@@ -174,6 +177,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;
@@ -216,7 +221,10 @@ private static void ReportAssertAreSameFailed(T? expected, T? actual, string?
#pragma warning disable IDE0060 // Remove unused parameter - https://github.com/dotnet/roslyn/issues/76578
public static void AreNotSame(T? notExpected, T? actual, [InterpolatedStringHandlerArgument(nameof(notExpected), nameof(actual))] ref AssertAreNotSameInterpolatedStringHandler message, [CallerArgumentExpression(nameof(notExpected))] string notExpectedExpression = "", [CallerArgumentExpression(nameof(actual))] string actualExpression = "")
#pragma warning restore IDE0060 // Remove unused parameter
- => message.ComputeAssertion(notExpectedExpression, actualExpression);
+ {
+ TelemetryCollector.TrackAssertionCall("Assert.AreNotSame");
+ message.ComputeAssertion(notExpectedExpression, actualExpression);
+ }
///
/// Tests whether the specified objects refer to different objects and
@@ -251,6 +259,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))
{
ReportAssertAreNotSameFailed(notExpected, actual, message, notExpectedExpression, actualExpression);
diff --git a/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs b/src/TestFramework/TestFramework/Assertions/Assert.Contains.cs
index ecdbfa1407..d06dca873a 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