File: Telemetry\CrashTelemetry.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.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
 
namespace Microsoft.Build.Framework.Telemetry;
 
/// <summary>
/// Classifies where a crash originated.
/// </summary>
internal enum CrashOriginKind
{
    /// <summary>
    /// The origin could not be determined (e.g., no stack trace available).
    /// </summary>
    Unknown,
 
    /// <summary>
    /// The crash originated in MSBuild's own code (Microsoft.Build.* namespaces).
    /// </summary>
    MSBuild,
 
    /// <summary>
    /// The crash originated in third-party or other Microsoft code running
    /// in the MSBuild process (e.g., VS telemetry SDK, NuGet, Roslyn).
    /// </summary>
    ThirdParty,
}
 
/// <summary>
/// Classifies the exit type / category of the crash for telemetry.
/// Maps to <c>MSBuildApp.ExitType</c> values plus additional categories
/// used by the unhandled exception handler and BuildManager.
/// </summary>
internal enum CrashExitType
{
    /// <summary>
    /// Default / unknown exit type.
    /// </summary>
    Unknown,
 
    /// <summary>
    /// A logger aborted the build.
    /// </summary>
    LoggerAbort,
 
    /// <summary>
    /// A logger failed unexpectedly.
    /// </summary>
    LoggerFailure,
 
    /// <summary>
    /// The build stopped unexpectedly, for example,
    /// because a child died or hung.
    /// </summary>
    Unexpected,
 
    /// <summary>
    /// A project cache failed unexpectedly.
    /// </summary>
    ProjectCacheFailure,
 
    /// <summary>
    /// The client for MSBuild server failed unexpectedly, for example,
    /// because the server process died or hung.
    /// </summary>
    MSBuildClientFailure,
 
    /// <summary>
    /// An exception reached the unhandled exception handler.
    /// </summary>
    UnhandledException,
 
    /// <summary>
    /// An exception occurred during EndBuild in BuildManager.
    /// </summary>
    EndBuildFailure,
 
    /// <summary>
    /// An OutOfMemoryException occurred.
    /// </summary>
    OutOfMemory,
}
 
/// <summary>
/// Telemetry data for MSBuild crashes and unhandled exceptions.
/// </summary>
internal class CrashTelemetry : TelemetryBase, IActivityTelemetryDataHolder
{
    public override string EventName => "crash";
 
    /// <summary>
    /// The full name of the exception type (e.g., "System.NullReferenceException").
    /// </summary>
    public string? ExceptionType { get; set; }
 
    /// <summary>
    /// Inner exception type, if any.
    /// </summary>
    public string? InnerExceptionType { get; set; }
 
    /// <summary>
    /// The exit type / category of the crash.
    /// </summary>
    public CrashExitType ExitType { get; set; }
 
    /// <summary>
    /// Whether the exception is classified as critical (OOM, StackOverflow, AccessViolation, etc.).
    /// </summary>
    public bool? IsCritical { get; set; }
 
    /// <summary>
    /// Whether the crash came from the unhandled exception handler (true) or a catch block (false).
    /// </summary>
    public bool IsUnhandled { get; set; }
 
    /// <summary>
    /// SHA-256 hash of the stack trace, for bucketing without sending PII.
    /// </summary>
    public string? StackHash { get; set; }
 
    /// <summary>
    /// The method at the top of the call stack where the exception originated.
    /// </summary>
    public string? StackTop { get; set; }
 
    /// <summary>
    /// The HResult from the exception, if available.
    /// </summary>
    public int? HResult { get; set; }
 
    /// <summary>
    /// Version of MSBuild.
    /// </summary>
    public string? BuildEngineVersion { get; set; }
 
    /// <summary>
    /// Framework name (.NET 10.0, .NET Framework 4.7.2, etc.).
    /// </summary>
    public string? BuildEngineFrameworkName { get; set; }
 
    /// <summary>
    /// Host in which MSBuild is running (VS, VSCode, CLI, etc.).
    /// </summary>
    public string? BuildEngineHost { get; set; }
 
    /// <summary>
    /// The origin classification of the crash.
    /// Helps distinguish crashes in MSBuild's own code from crashes in dependencies
    /// that happen to run in the MSBuild process.
    /// </summary>
    public CrashOriginKind CrashOrigin { get; set; }
 
    /// <summary>
    /// The top-level namespace from the faulting stack frame (e.g., "Microsoft.Build",
    /// "Microsoft.VisualStudio.RemoteControl"). Useful for triage without revealing PII.
    /// </summary>
    public string? CrashOriginNamespace { get; set; }
 
    /// <summary>
    /// The deepest inner exception type in the exception chain.
    /// For wrapper exceptions like <see cref="System.TypeInitializationException"/>,
    /// this reveals the actual root cause exception type.
    /// </summary>
    public string? InnermostExceptionType { get; set; }
 
    /// <summary>
    /// Working set of the MSBuild process at crash time, in MB.
    /// Helps diagnose OOM and memory-pressure crashes.
    /// </summary>
    public long? ProcessWorkingSetMB { get; set; }
 
    /// <summary>
    /// Approximate percentage of physical memory in use at crash time (0-100).
    /// Available on Windows (.NET Framework via GlobalMemoryStatusEx) and
    /// .NET Core (via GC.GetGCMemoryInfo).
    /// </summary>
    public int? MemoryLoadPercent { get; set; }
 
    /// <summary>
    /// The original exception, kept for passing to <c>FaultEvent</c>.
    /// Not serialized to telemetry properties.
    /// </summary>
    internal Exception? Exception { get; set; }
 
    /// <summary>
    /// Populates this instance from an exception.
    /// </summary>
    public void PopulateFromException(Exception exception)
    {
        Exception = exception;
        ExceptionType = exception.GetType().FullName;
        InnerExceptionType = exception.InnerException?.GetType().FullName;
        InnermostExceptionType = GetInnermostException(exception)?.GetType().FullName;
        HResult = exception.HResult;
        StackHash = ComputeStackHash(exception);
        StackTop = ExtractStackTop(exception);
        CrashOriginNamespace = ExtractOriginNamespace(exception);
        CrashOrigin = ClassifyOrigin(CrashOriginNamespace);
        PopulateMemoryStats();
    }
 
    /// <summary>
    /// Captures memory usage stats at the time of the crash.
    /// Best-effort: failures are silently ignored.
    /// </summary>
    private void PopulateMemoryStats()
    {
        try
        {
            using var process = System.Diagnostics.Process.GetCurrentProcess();
            ProcessWorkingSetMB = process.WorkingSet64 / (1024 * 1024);
        }
        catch
        {
            // Best effort.
        }
 
        try
        {
#if NETFRAMEWORK
            NativeMethods.MemoryStatus? memoryStatus = NativeMethods.GetMemoryStatus();
            if (memoryStatus != null)
            {
                MemoryLoadPercent = (int)memoryStatus.MemoryLoad;
            }
#else
            // On .NET Core, GC.GetGCMemoryInfo() provides the total available memory
            // to the GC, which we use to compute an approximate memory load percentage.
            // This helps diagnose OOM and memory-pressure crashes on Linux/macOS where
            // GlobalMemoryStatusEx is not available.
            GCMemoryInfo gcMemInfo = System.GC.GetGCMemoryInfo();
            long totalAvailable = gcMemInfo.TotalAvailableMemoryBytes;
            if (totalAvailable > 0)
            {
                using var process = System.Diagnostics.Process.GetCurrentProcess();
                MemoryLoadPercent = (int)(((double)process.WorkingSet64 / totalAvailable) * 100);
            }
#endif
        }
        catch
        {
            // Best effort.
        }
    }
 
    /// <summary>
    /// Create a list of properties sent to VS telemetry as activity tags.
    /// </summary>
    public Dictionary<string, object> GetActivityProperties()
    {
        Dictionary<string, object> telemetryItems = new(10);
 
        AddIfNotNull(ExceptionType);
        AddIfNotNull(InnerExceptionType);
        if (ExitType != CrashExitType.Unknown)
        {
            telemetryItems.Add(nameof(ExitType), ExitType.ToString());
        }
        AddIfNotNull(IsCritical);
        AddIfNotNull(IsUnhandled);
        AddIfNotNull(StackHash);
        AddIfNotNull(StackTop);
        AddIfNotNull(HResult);
        AddIfNotNull(BuildEngineVersion);
        AddIfNotNull(BuildEngineFrameworkName);
        AddIfNotNull(BuildEngineHost);
        if (CrashOrigin != CrashOriginKind.Unknown)
        {
            telemetryItems.Add(nameof(CrashOrigin), CrashOrigin.ToString());
        }
        AddIfNotNull(CrashOriginNamespace);
        AddIfNotNull(InnermostExceptionType);
        AddIfNotNull(ProcessWorkingSetMB);
        AddIfNotNull(MemoryLoadPercent);
 
        return telemetryItems;
 
        void AddIfNotNull(object? value, [CallerArgumentExpression(nameof(value))] string key = "")
        {
            if (value is not null)
            {
                telemetryItems.Add(key, value);
            }
        }
    }
 
    public override IDictionary<string, string> GetProperties()
    {
        var properties = new Dictionary<string, string>();
 
        AddIfNotNull(ExceptionType);
        AddIfNotNull(InnerExceptionType);
        if (ExitType != CrashExitType.Unknown)
        {
            AddIfNotNull(ExitType.ToString(), nameof(ExitType));
        }
        AddIfNotNull(IsCritical?.ToString(), nameof(IsCritical));
        AddIfNotNull(IsUnhandled.ToString(), nameof(IsUnhandled));
        AddIfNotNull(StackHash);
        AddIfNotNull(StackTop);
        AddIfNotNull(HResult?.ToString(), nameof(HResult));
        AddIfNotNull(BuildEngineVersion);
        AddIfNotNull(BuildEngineFrameworkName);
        AddIfNotNull(BuildEngineHost);
        if (CrashOrigin != CrashOriginKind.Unknown)
        {
            AddIfNotNull(CrashOrigin.ToString(), nameof(CrashOrigin));
        }
        AddIfNotNull(CrashOriginNamespace);
        AddIfNotNull(InnermostExceptionType);
        AddIfNotNull(ProcessWorkingSetMB?.ToString(), nameof(ProcessWorkingSetMB));
        AddIfNotNull(MemoryLoadPercent?.ToString(), nameof(MemoryLoadPercent));
 
        return properties;
 
        void AddIfNotNull(string? value, [CallerArgumentExpression(nameof(value))] string key = "")
        {
            if (value is not null)
            {
                properties[key] = value;
            }
        }
    }
 
    /// <summary>
    /// Known namespace prefixes that indicate the crash originated in MSBuild code.
    /// </summary>
    private static readonly string[] s_msBuildNamespacePrefixes =
    [
        "Microsoft.Build",
    ];
 
    /// <summary>
    /// Walks the inner exception chain and returns the deepest (innermost) exception.
    /// Returns null if the exception has no inner exception.
    /// Guards against circular references with a depth limit.
    /// </summary>
    private static Exception? GetInnermostException(Exception exception)
    {
        Exception? inner = exception.InnerException;
        if (inner is null)
        {
            return null;
        }
 
        // Guard against circular references — 20 levels is more than enough
        // for any real exception chain.
        const int maxDepth = 20;
        int depth = 0;
        while (inner.InnerException is not null && depth < maxDepth)
        {
            inner = inner.InnerException;
            depth++;
        }
 
        return inner;
    }
 
    /// <summary>
    /// Extracts the top-level namespace from the first stack frame of the exception.
    /// Returns null if the stack trace is unavailable or cannot be parsed.
    /// Uses index-based parsing to avoid intermediate string allocations.
    /// </summary>
    internal static string? ExtractOriginNamespace(Exception exception)
    {
        string? stackTrace = exception.StackTrace;
        if (stackTrace is null)
        {
            return null;
        }
 
        // Get first line boundaries: "   at Namespace.Type.Method(...) in path:line N"
        int lineEnd = stackTrace.IndexOf('\n');
        if (lineEnd < 0)
        {
            lineEnd = stackTrace.Length;
        }
 
        // Find start of qualified name (skip leading whitespace and "at ")
        int start = 0;
        while (start < lineEnd && char.IsWhiteSpace(stackTrace[start]))
        {
            start++;
        }
 
        if (start + 3 <= lineEnd &&
            stackTrace[start] == 'a' && stackTrace[start + 1] == 't' && stackTrace[start + 2] == ' ')
        {
            start += 3;
        }
 
        // Find end of qualified name: stop at '(' or " in "
        int end = lineEnd;
        int parenIndex = stackTrace.IndexOf('(', start, end - start);
        if (parenIndex >= 0)
        {
            end = parenIndex;
        }
 
        int inIndex = stackTrace.IndexOf(" in ", start, end - start, StringComparison.Ordinal);
        if (inIndex >= 0)
        {
            end = inIndex;
        }
 
        // Trim trailing whitespace
        while (end > start && char.IsWhiteSpace(stackTrace[end - 1]))
        {
            end--;
        }
 
        if (end <= start)
        {
            return null;
        }
 
        // "Namespace.Sub.Type.Method" → count dots to determine segment count,
        // then take up to 3 segments excluding the last one (Method).
        int dotCount = 0;
        for (int i = start; i < end; i++)
        {
            if (stackTrace[i] == '.')
            {
                dotCount++;
            }
        }
 
        if (dotCount < 2)
        {
            // Not enough segments — return first segment (or the whole thing if no dots)
            int firstDot = stackTrace.IndexOf('.', start, end - start);
            return firstDot >= 0
                ? stackTrace.Substring(start, firstDot - start)
                : stackTrace.Substring(start, end - start);
        }
 
        // Walk forward to find the end of the Nth namespace segment (up to 3,
        // but at most dotCount - 2 to exclude Type.Method).
        int take = Math.Min(3, dotCount - 1); // -1 because last segment after last dot is Method
        int dotsFound = 0;
        int cutoff = start;
        for (int i = start; i < end; i++)
        {
            if (stackTrace[i] == '.')
            {
                dotsFound++;
                if (dotsFound == take)
                {
                    cutoff = i;
                    break;
                }
            }
        }
 
        return cutoff > start ? stackTrace.Substring(start, cutoff - start) : null;
    }
 
    /// <summary>
    /// Classifies the crash origin based on the faulting namespace.
    /// Returns <see cref="CrashOriginKind.MSBuild"/> if the namespace starts with a known MSBuild prefix,
    /// <see cref="CrashOriginKind.ThirdParty"/> if it doesn't, or <see cref="CrashOriginKind.Unknown"/>
    /// if no namespace could be determined.
    /// </summary>
    internal static CrashOriginKind ClassifyOrigin(string? originNamespace)
    {
        if (string.IsNullOrEmpty(originNamespace))
        {
            return CrashOriginKind.Unknown;
        }
 
        foreach (string prefix in s_msBuildNamespacePrefixes)
        {
            if (originNamespace!.Equals(prefix, StringComparison.OrdinalIgnoreCase)
                || (originNamespace.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
                    && originNamespace.Length > prefix.Length
                    && originNamespace[prefix.Length] == '.'))
            {
                return CrashOriginKind.MSBuild;
            }
        }
 
        return CrashOriginKind.ThirdParty;
    }
 
    /// <summary>
    /// Computes a SHA-256 hash of the exception stack trace for bucketing without PII.
    /// </summary>
    private static string? ComputeStackHash(Exception exception)
    {
        string? stackTrace = exception.StackTrace;
        if (stackTrace is null)
        {
            return null;
        }
 
#if NET
        byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(stackTrace));
        return Convert.ToHexString(hashBytes);
#else
        using SHA256 sha256 = SHA256.Create();
        byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(stackTrace));
        StringBuilder sb = new(hashBytes.Length * 2);
        foreach (byte b in hashBytes)
        {
            sb.Append(b.ToString("X2"));
        }
 
        return sb.ToString();
#endif
    }
 
    /// <summary>
    /// Extracts the top frame of the stack trace to identify the crash location.
    /// </summary>
    private static string? ExtractStackTop(Exception exception)
    {
        string? stackTrace = exception.StackTrace;
        if (stackTrace is null)
        {
            return null;
        }
 
        // Get the first line of the stack trace (the top frame).
        int newLineIndex = stackTrace.IndexOf('\n');
        string topFrame = newLineIndex >= 0 ? stackTrace.Substring(0, newLineIndex) : stackTrace;
        return SanitizeStackFrame(topFrame.Trim());
    }
 
    /// <summary>
    /// Redacts file paths from a stack frame to avoid leaking PII (e.g. usernames in paths).
    /// Preserves the method signature and line number.
    /// </summary>
    private static string SanitizeStackFrame(string frame)
    {
        if (string.IsNullOrEmpty(frame))
        {
            return frame;
        }
 
        // Typical .NET stack frame:
        //   at Namespace.Type.Method() in C:\Users\username\path\file.cs:line 123
        const string inToken = " in ";
        const string lineToken = ":line ";
 
        int inIndex = frame.IndexOf(inToken, StringComparison.Ordinal);
        if (inIndex < 0)
        {
            return frame;
        }
 
        int lineIndex = frame.IndexOf(lineToken, inIndex, StringComparison.Ordinal);
        if (lineIndex < 0)
        {
            return frame.Substring(0, inIndex + inToken.Length) + "<redacted>";
        }
 
        string prefix = frame.Substring(0, inIndex + inToken.Length);
        string lineSuffix = frame.Substring(lineIndex);
        return prefix + "<redacted>" + lineSuffix;
    }
}