diff --git a/src/Build.UnitTests/BackEnd/ProjectTelemetry_Tests.cs b/src/Build.UnitTests/BackEnd/ProjectTelemetry_Tests.cs
new file mode 100644
index 00000000000..51d20a9b041
--- /dev/null
+++ b/src/Build.UnitTests/BackEnd/ProjectTelemetry_Tests.cs
@@ -0,0 +1,184 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using Microsoft.Build.BackEnd.Logging;
+using Microsoft.Build.Evaluation;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using Shouldly;
+using Xunit;
+
+namespace Microsoft.Build.UnitTests.BackEnd
+{
+ ///
+ /// Tests for ProjectTelemetry class
+ ///
+ public class ProjectTelemetry_Tests
+ {
+ ///
+ /// Test that TrackTaskSubclassing tracks sealed tasks that derive from Microsoft tasks
+ ///
+ [Fact]
+ public void TrackTaskSubclassing_TracksSealedTasks()
+ {
+ var telemetry = new ProjectTelemetry();
+
+ // Sealed task should be tracked if it derives from Microsoft task
+ telemetry.TrackTaskSubclassing(typeof(TestSealedTask), isMicrosoftOwned: false);
+
+ var properties = GetMSBuildTaskSubclassProperties(telemetry);
+
+ // Should track sealed tasks that inherit from Microsoft tasks
+ properties.Count.ShouldBe(1);
+ properties.ShouldContainKey("Microsoft_Build_Utilities_Task");
+ properties["Microsoft_Build_Utilities_Task"].ShouldBe("1");
+ }
+
+ ///
+ /// Test that TrackTaskSubclassing tracks subclasses of Microsoft tasks
+ ///
+ [Fact]
+ public void TrackTaskSubclassing_TracksSubclass()
+ {
+ var telemetry = new ProjectTelemetry();
+
+ // User task inheriting from Microsoft.Build.Utilities.Task
+ telemetry.TrackTaskSubclassing(typeof(UserTask), isMicrosoftOwned: false);
+
+ var properties = GetMSBuildTaskSubclassProperties(telemetry);
+
+ // Should track the Microsoft.Build.Utilities.Task base class
+ properties.Count.ShouldBe(1);
+ properties.ShouldContainKey("Microsoft_Build_Utilities_Task");
+ properties["Microsoft_Build_Utilities_Task"].ShouldBe("1");
+ }
+
+ ///
+ /// Test that TrackTaskSubclassing does not track Microsoft-owned tasks
+ ///
+ [Fact]
+ public void TrackTaskSubclassing_IgnoresMicrosoftOwnedTasks()
+ {
+ var telemetry = new ProjectTelemetry();
+
+ // Microsoft-owned task should not be tracked even if non-sealed
+ telemetry.TrackTaskSubclassing(typeof(UserTask), isMicrosoftOwned: true);
+
+ var properties = GetMSBuildTaskSubclassProperties(telemetry);
+
+ // Should not track Microsoft-owned tasks
+ properties.Count.ShouldBe(0);
+ }
+
+ ///
+ /// Test that TrackTaskSubclassing tracks multiple subclasses
+ ///
+ [Fact]
+ public void TrackTaskSubclassing_TracksMultipleSubclasses()
+ {
+ var telemetry = new ProjectTelemetry();
+
+ // Track multiple user tasks
+ telemetry.TrackTaskSubclassing(typeof(UserTask), isMicrosoftOwned: false);
+ telemetry.TrackTaskSubclassing(typeof(AnotherUserTask), isMicrosoftOwned: false);
+
+ var properties = GetMSBuildTaskSubclassProperties(telemetry);
+
+ // Should aggregate counts for the same base class
+ properties.Count.ShouldBe(1);
+ properties["Microsoft_Build_Utilities_Task"].ShouldBe("2");
+ }
+
+ ///
+ /// Test that TrackTaskSubclassing handles null gracefully
+ ///
+ [Fact]
+ public void TrackTaskSubclassing_HandlesNull()
+ {
+ var telemetry = new ProjectTelemetry();
+
+#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type
+ telemetry.TrackTaskSubclassing(null, isMicrosoftOwned: false);
+#pragma warning restore CS8625
+
+ var properties = GetMSBuildTaskSubclassProperties(telemetry);
+
+ properties.Count.ShouldBe(0);
+ }
+
+ ///
+ /// Helper method to get MSBuild task subclass properties from telemetry using reflection
+ ///
+ private System.Collections.Generic.Dictionary GetMSBuildTaskSubclassProperties(ProjectTelemetry telemetry)
+ {
+ var method = typeof(ProjectTelemetry).GetMethod("GetMSBuildTaskSubclassProperties",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ return (System.Collections.Generic.Dictionary)method!.Invoke(telemetry, null)!;
+ }
+
+ ///
+ /// Non-sealed user task that inherits from Microsoft.Build.Utilities.Task
+ ///
+#pragma warning disable CA1852 // Type can be sealed
+ private class UserTask : Task
+#pragma warning restore CA1852
+ {
+ public override bool Execute()
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Another non-sealed user task that inherits from Microsoft.Build.Utilities.Task
+ ///
+#pragma warning disable CA1852 // Type can be sealed
+ private class AnotherUserTask : Task
+#pragma warning restore CA1852
+ {
+ public override bool Execute()
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Sealed task that inherits from Microsoft.Build.Utilities.Task
+ ///
+ private sealed class TestSealedTask : Task
+ {
+ public override bool Execute()
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Integration test that verifies telemetry is logged during a build with Microsoft tasks
+ ///
+ [Fact]
+ public void MSBuildTaskTelemetry_IsLoggedDuringBuild()
+ {
+ string projectContent = @"
+
+
+
+
+ ";
+
+ var events = new System.Collections.Generic.List();
+ var logger = new Microsoft.Build.Logging.ConsoleLogger(LoggerVerbosity.Diagnostic);
+
+ using var projectCollection = new ProjectCollection();
+ using var stringReader = new System.IO.StringReader(projectContent);
+ using var xmlReader = System.Xml.XmlReader.Create(stringReader);
+ var project = new Project(xmlReader, null, null, projectCollection);
+
+ // Build the project
+ var result = project.Build();
+
+ result.ShouldBeTrue();
+ }
+ }
+}
diff --git a/src/Build/BackEnd/Components/Logging/ProjectTelemetry.cs b/src/Build/BackEnd/Components/Logging/ProjectTelemetry.cs
index b0087eff762..89984c15b9d 100644
--- a/src/Build/BackEnd/Components/Logging/ProjectTelemetry.cs
+++ b/src/Build/BackEnd/Components/Logging/ProjectTelemetry.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System;
using System.Collections.Generic;
using System.Globalization;
using Microsoft.Build.Framework;
@@ -25,6 +26,7 @@ internal class ProjectTelemetry
// This means that if we ever add logging non-integer properties for these events, they will not be included in the telemetry.
private const string TaskFactoryEventName = "build/tasks/taskfactory";
private const string TasksEventName = "build/tasks";
+ private const string MSBuildTaskSubclassedEventName = "build/tasks/msbuild-subclassed";
private int _assemblyTaskFactoryTasksExecutedCount = 0;
private int _intrinsicTaskFactoryTasksExecutedCount = 0;
@@ -35,6 +37,10 @@ internal class ProjectTelemetry
private int _taskHostTasksExecutedCount = 0;
+ // Telemetry for non-sealed subclasses of Microsoft-owned MSBuild tasks
+ // Maps Microsoft task names to counts of their non-sealed usage
+ private readonly Dictionary _msbuildTaskSubclassUsage = new();
+
///
/// Adds a task execution to the telemetry data.
///
@@ -73,6 +79,47 @@ public void AddTaskExecution(string taskFactoryTypeName, bool isTaskHost)
}
}
+ ///
+ /// Tracks subclasses of Microsoft-owned MSBuild tasks.
+ /// If the task is a subclass of a Microsoft-owned task, increments the usage count for that base task.
+ ///
+ /// The type of the task being loaded.
+ /// Whether the task itself is Microsoft-owned.
+ public void TrackTaskSubclassing(Type taskType, bool isMicrosoftOwned)
+ {
+ if (taskType == null)
+ {
+ return;
+ }
+
+ // Walk the inheritance hierarchy to find Microsoft-owned base tasks
+ Type? baseType = taskType.BaseType;
+ while (baseType != null)
+ {
+ // Check if this base type is a Microsoft-owned task
+ // We identify Microsoft tasks by checking if they're in the Microsoft.Build namespace
+ string? baseTypeName = baseType.FullName;
+ if (!string.IsNullOrEmpty(baseTypeName) &&
+ (baseTypeName.StartsWith("Microsoft.Build.Tasks.") ||
+ baseTypeName.StartsWith("Microsoft.Build.Utilities.")))
+ {
+ // This is a subclass of a Microsoft-owned task
+ // Track it only if it's NOT itself Microsoft-owned (i.e., user-authored subclass)
+ if (!isMicrosoftOwned)
+ {
+ if (!_msbuildTaskSubclassUsage.ContainsKey(baseTypeName))
+ {
+ _msbuildTaskSubclassUsage[baseTypeName] = 0;
+ }
+ _msbuildTaskSubclassUsage[baseTypeName]++;
+ }
+ // Stop at the first Microsoft-owned base class we find
+ break;
+ }
+ baseType = baseType.BaseType;
+ }
+ }
+
///
/// Logs telemetry data for a project
///
@@ -96,6 +143,12 @@ public void LogProjectTelemetry(ILoggingService loggingService, BuildEventContex
{
loggingService.LogTelemetry(buildEventContext, TasksEventName, taskTotalProperties);
}
+
+ Dictionary msbuildTaskSubclassProperties = GetMSBuildTaskSubclassProperties();
+ if (msbuildTaskSubclassProperties.Count > 0)
+ {
+ loggingService.LogTelemetry(buildEventContext, MSBuildTaskSubclassedEventName, msbuildTaskSubclassProperties);
+ }
}
catch
{
@@ -120,6 +173,8 @@ private void Clean()
_customTaskFactoryTasksExecutedCount = 0;
_taskHostTasksExecutedCount = 0;
+
+ _msbuildTaskSubclassUsage.Clear();
}
private Dictionary GetTaskFactoryProperties()
@@ -182,5 +237,20 @@ private Dictionary GetTaskProperties()
return properties;
}
+
+ private Dictionary GetMSBuildTaskSubclassProperties()
+ {
+ Dictionary properties = new();
+
+ // Add each Microsoft task name with its non-sealed subclass usage count
+ foreach (var kvp in _msbuildTaskSubclassUsage)
+ {
+ // Use a sanitized property name (replace dots with underscores for telemetry)
+ string propertyName = kvp.Key.Replace(".", "_");
+ properties[propertyName] = kvp.Value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ return properties;
+ }
}
}
diff --git a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs
index 47b49b00e75..b02f17b639b 100644
--- a/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs
+++ b/src/Build/Instance/TaskFactories/AssemblyTaskFactory.cs
@@ -439,6 +439,13 @@ internal ITask CreateTaskInstance(
}
#endif
+ // Track non-sealed subclasses of Microsoft-owned MSBuild tasks
+ if (taskInstance != null)
+ {
+ bool isMicrosoftOwned = IsMicrosoftAuthoredTask();
+ _taskLoggingContext?.TargetLoggingContext?.ProjectLoggingContext?.ProjectTelemetry?.TrackTaskSubclassing(_loadedType.Type, isMicrosoftOwned);
+ }
+
return taskInstance;
}
}
@@ -691,6 +698,43 @@ private static bool TaskHostParametersMatchCurrentProcess(IDictionary
+ /// Determines whether the current task is Microsoft-authored based on the assembly location and name.
+ ///
+ private bool IsMicrosoftAuthoredTask()
+ {
+ if (_loadedType?.Type == null)
+ {
+ return false;
+ }
+
+ // Check if the assembly is a Microsoft assembly by name
+ string assemblyName = _loadedType.Assembly.AssemblyName;
+ if (!string.IsNullOrEmpty(assemblyName) && Framework.FileClassifier.IsMicrosoftAssembly(assemblyName))
+ {
+ return true;
+ }
+
+ // Check if the assembly is from a Microsoft-controlled location
+ string assemblyFile = _loadedType.Assembly.AssemblyFile;
+ if (!string.IsNullOrEmpty(assemblyFile))
+ {
+ // Check if it's built-in MSBuild logic (e.g., from MSBuild installation)
+ if (Framework.FileClassifier.Shared.IsBuiltInLogic(assemblyFile))
+ {
+ return true;
+ }
+
+ // Check if it's a Microsoft package from NuGet cache
+ if (Framework.FileClassifier.Shared.IsMicrosoftPackageInNugetCache(assemblyFile))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
///
/// Log errors from TaskLoader.
///