File: Telemetry\BuildTelemetry.cs
Web Access
Project: ..\..\..\src\Framework\Microsoft.Build.Framework.csproj (Microsoft.Build.Framework)
// 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 System.IO;
using System.Runtime.CompilerServices;
using static Microsoft.Build.Framework.Telemetry.BuildInsights;
 
namespace Microsoft.Build.Framework.Telemetry
{
    /// <summary>
    /// Telemetry of build.
    /// </summary>
    internal class BuildTelemetry : TelemetryBase, IActivityTelemetryDataHolder
    {
        public override string EventName => "build";
 
        /// <summary>
        /// Time at which build have started.
        /// </summary>
        /// <remarks>
        /// It is time when build started, not when BuildManager start executing build.
        /// For example in case of MSBuild Server it is time before we connected or launched MSBuild Server.
        /// </remarks>
        public DateTime? StartAt { get; set; }
 
        /// <summary>
        /// Time at which inner build have started.
        /// </summary>
        /// <remarks>
        /// It is time when build internally started, i.e. when BuildManager starts it.
        /// In case of MSBuild Server it is time when Server starts build.
        /// </remarks>
        public DateTime? InnerStartAt { get; set; }
 
        /// <summary>
        /// True if MSBuild runs from command line.
        /// </summary>
        public bool? IsStandaloneExecution { get; set; }
 
        /// <summary>
        /// Time at which build have finished.
        /// </summary>
        public DateTime? FinishedAt { get; set; }
 
        /// <summary>
        /// Overall build success.
        /// </summary>
        public bool? BuildSuccess { get; set; }
 
        /// <summary>
        /// Build Target.
        /// </summary>
        public string? BuildTarget { get; set; }
 
        /// <summary>
        /// MSBuild server fallback reason.
        /// Either "ServerBusy", "ConnectionError" or null (no fallback).
        /// </summary>
        public string? ServerFallbackReason { get; set; }
 
        /// <summary>
        /// Version of MSBuild.
        /// </summary>
        public Version? BuildEngineVersion { get; set; }
 
        /// <summary>
        /// Display version of the Engine suitable for display to a user.
        /// </summary>
        public string? BuildEngineDisplayVersion { get; set; }
 
        /// <summary>
        /// Path to project file.
        /// Only the file name (no directory) is emitted in telemetry to avoid leaking PII
        /// (usernames, directory structure) that are commonly embedded in full paths.
        /// </summary>
        public string? ProjectPath { get; set; }
 
        /// <summary>
        /// Well-known target names that are safe to emit in cleartext.
        /// Custom target names could reveal project internals and are hashed.
        /// </summary>
        private static readonly HashSet<string> KnownTargetNames = new(StringComparer.OrdinalIgnoreCase)
        {
            "Build",
            "Clean",
            "Rebuild",
            "Restore",
            "Pack",
            "Publish",
            "Test",
            "VSTest",
            "Run",
            "GetTargetFrameworks",
            "GetTargetFrameworksWithPlatformForSingleTargetFramework",
            "GetReferenceNearestTargetFrameworkTask",
            "GetTargetPath",
            "GetNativeManifest",
            "ResolveAssemblyReferences",
            "ResolveProjectReferences",
            "CoreCompile",
            "Compile",
            "PrepareForBuild",
            "GenerateBuildDependencyFile",
            "GenerateBindingRedirects",
            "GenerateRuntimeConfigurationFiles",
        };
 
        /// <summary>
        /// Host in which MSBuild build was executed.
        /// For example: "VS", "VSCode", "Azure DevOps", "GitHub Action", "CLI", ...
        /// </summary>
        public string? BuildEngineHost { get; set; }
 
        /// <summary>
        /// True if buildcheck was used.
        /// </summary>
        public bool? BuildCheckEnabled { get; set; }
 
        /// <summary>
        /// True if multithreaded mode was enabled.
        /// </summary>
        public bool? MultiThreadedModeEnabled { get; set; }
 
        /// <summary>
        /// True if Smart Application Control was enabled.
        /// </summary>
        public bool? SACEnabled { get; set; }
 
        /// <summary>
        /// State of MSBuild server process before this build.
        /// One of 'cold', 'hot', null (if not run as server)
        /// </summary>
        public string? InitialMSBuildServerState { get; set; }
 
        /// <summary>
        /// Framework name suitable for display to a user.
        /// </summary>
        public string? BuildEngineFrameworkName { get; set; }
 
        /// <summary>
        /// Primary failure category when BuildSuccess = false.
        /// One of: "Compiler", "MSBuildEngine", "Tasks", "SDKResolvers", "NETSDK", "NuGet", "BuildCheck", "Other".
        /// </summary>
        public string? FailureCategory { get; set; }
 
        /// <summary>
        /// Error counts by category.
        /// </summary>
        public ErrorCountsInfo? ErrorCounts { get; set; }
 
        /// <summary>
        /// Create a list of properties sent to VS telemetry.
        /// </summary>
        public Dictionary<string, object> GetActivityProperties()
        {
            Dictionary<string, object> telemetryItems = new(8);
 
            if (StartAt.HasValue && FinishedAt.HasValue)
            {
                telemetryItems.Add(TelemetryConstants.BuildDurationPropertyName, (FinishedAt.Value - StartAt.Value).TotalMilliseconds);
            }
 
            if (InnerStartAt.HasValue && FinishedAt.HasValue)
            {
                telemetryItems.Add(TelemetryConstants.InnerBuildDurationPropertyName, (FinishedAt.Value - InnerStartAt.Value).TotalMilliseconds);
            }
 
            AddIfNotNull(BuildEngineHost);
            AddIfNotNull(BuildSuccess);
            AddIfNotNull(SanitizeBuildTarget(BuildTarget), nameof(BuildTarget));
            AddIfNotNull(BuildEngineVersion);
            AddIfNotNull(BuildCheckEnabled);
            AddIfNotNull(MultiThreadedModeEnabled);
            AddIfNotNull(SACEnabled);
            AddIfNotNull(IsStandaloneExecution);
            AddIfNotNull(FailureCategory);
            AddIfNotNull(ErrorCounts);
 
            return telemetryItems;
 
            void AddIfNotNull(object? value, [CallerArgumentExpression(nameof(value))] string key = "")
            {
                if (value != null)
                {
                    telemetryItems.Add(key, value);
                }
            }
        }
 
        public override IDictionary<string, string> GetProperties()
        {
            var properties = new Dictionary<string, string>();
 
            AddIfNotNull(BuildEngineDisplayVersion);
            AddIfNotNull(BuildEngineFrameworkName);
            AddIfNotNull(BuildEngineHost);
            AddIfNotNull(InitialMSBuildServerState);
            AddIfNotNull(ProjectPath != null ? Path.GetFileName(ProjectPath) : null, nameof(ProjectPath));
            AddIfNotNull(ServerFallbackReason);
            AddIfNotNull(SanitizeBuildTarget(BuildTarget), nameof(BuildTarget));
            AddIfNotNull(BuildEngineVersion?.ToString(), nameof(BuildEngineVersion));
            AddIfNotNull(BuildSuccess?.ToString(), nameof(BuildSuccess));
            AddIfNotNull(BuildCheckEnabled?.ToString(), nameof(BuildCheckEnabled));
            AddIfNotNull(MultiThreadedModeEnabled?.ToString(), nameof(MultiThreadedModeEnabled));
            AddIfNotNull(SACEnabled?.ToString(), nameof(SACEnabled));
            AddIfNotNull(IsStandaloneExecution?.ToString(), nameof(IsStandaloneExecution));
            AddIfNotNull(FailureCategory);
            AddIfNotNull(ErrorCounts?.ToString(), nameof(ErrorCounts));
 
            // Calculate durations
            if (StartAt.HasValue && FinishedAt.HasValue)
            {
                properties[TelemetryConstants.BuildDurationPropertyName] =
                    (FinishedAt.Value - StartAt.Value).TotalMilliseconds.ToString(CultureInfo.InvariantCulture);
            }
 
            if (InnerStartAt.HasValue && FinishedAt.HasValue)
            {
                properties[TelemetryConstants.InnerBuildDurationPropertyName] =
                    (FinishedAt.Value - InnerStartAt.Value).TotalMilliseconds.ToString(CultureInfo.InvariantCulture);
            }
 
            return properties;
 
            void AddIfNotNull(string? value, [CallerArgumentExpression(nameof(value))] string key = "")
            {
                if (value != null)
                {
                    properties[key] = value;
                }
            }
        }
 
        /// <summary>
        /// Returns the build target name if it is a well-known target, otherwise returns a SHA-256 hash.
        /// This prevents custom target names (which could reveal proprietary project details) from being
        /// sent in cleartext telemetry.
        /// BuildTarget may be a comma-separated list of target names (e.g., "Build,Clean"),
        /// so each target is sanitized individually.
        /// </summary>
        internal static string? SanitizeBuildTarget(string? buildTarget)
        {
            if (buildTarget is null)
            {
                return null;
            }
 
            // BuildTarget can be a comma-separated list (set via string.Join(",", targetNames)).
            // Split, sanitize each target individually, and rejoin.
            string[] targets = buildTarget.Split(',');
            for (int i = 0; i < targets.Length; i++)
            {
                string target = targets[i].Trim();
                targets[i] = KnownTargetNames.Contains(target)
                    ? target
                    : TelemetryDataUtils.GetHashed(target);
            }
 
            return string.Join(",", targets);
        }
    }
}