File: Telemetry\CrashTelemetryRecorder.cs
Web Access
Project: 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.Runtime.CompilerServices;
#if NETFRAMEWORK
using Microsoft.VisualStudio.Telemetry;
#endif

namespace Microsoft.Build.Framework.Telemetry;

/// <summary>
/// Centralized helper for recording and flushing crash/failure telemetry.
/// All methods are best-effort and will never throw.
/// </summary>
internal static class CrashTelemetryRecorder
{
    /// <summary>
    /// Interval in milliseconds between EndBuild hang diagnostic emissions.
    /// When EndBuild is stuck waiting for submissions or nodes, diagnostics are emitted at this interval.
    /// </summary>
    public const int EndBuildHangDiagnosticsIntervalMs = 30_000;

    /// <summary>
    /// Records crash telemetry data for later emission via <see cref="FlushCrashTelemetry"/>.
    /// </summary>
    /// <param name="exception">The exception that caused the crash.</param>
    /// <param name="exitType">Exit type classification.</param>
    /// <param name="isUnhandled">True if the exception was not caught by any catch block.</param>
    /// <param name="isCritical">Whether the exception is classified as critical (OOM, StackOverflow, etc.).</param>
    /// <param name="buildEngineVersion">MSBuild version string, if available.</param>
    /// <param name="buildEngineFrameworkName">Framework name, if available.</param>
    /// <param name="buildEngineHost">Host name (VS, VSCode, CLI, etc.), if available.</param>
    /// <param name="isStandaloneExecution">True if MSBuild runs from command line, false if hosted.</param>
    /// <param name="maxNodeCount">Maximum number of build nodes configured.</param>
    /// <param name="activeNodeCount">Number of currently active build nodes at crash time.</param>
    /// <param name="submissionCount">Number of active build submissions at crash time.</param>
    public static void RecordCrashTelemetry(
        Exception exception,
        CrashExitType exitType,
        bool isUnhandled,
        bool isCritical,
        string? buildEngineVersion = null,
        string? buildEngineFrameworkName = null,
        string? buildEngineHost = null,
        bool? isStandaloneExecution = null,
        int? maxNodeCount = null,
        int? activeNodeCount = null,
        int? submissionCount = null)
    {
        try
        {
            CrashTelemetry crashTelemetry = CreateCrashTelemetry(exception, exitType, isUnhandled, isCritical);
            crashTelemetry.BuildEngineVersion = buildEngineVersion;
            crashTelemetry.BuildEngineFrameworkName = buildEngineFrameworkName;
            crashTelemetry.BuildEngineHost = buildEngineHost;
            crashTelemetry.IsStandaloneExecution = isStandaloneExecution;
            crashTelemetry.MaxNodeCount = maxNodeCount;
            crashTelemetry.ActiveNodeCount = activeNodeCount;
            crashTelemetry.SubmissionCount = submissionCount;
            KnownTelemetry.CrashTelemetry = crashTelemetry;
        }
        catch
        {
            // Best effort: telemetry must never cause a secondary failure.
        }
    }

    /// <summary>
    /// Records crash telemetry and immediately flushes it.
    /// Use when the process is about to terminate (e.g. unhandled exception handler).
    /// </summary>
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static void RecordAndFlushCrashTelemetry(
        Exception exception,
        CrashExitType exitType,
        bool isUnhandled,
        bool isCritical)
    {
        try
        {
            CrashTelemetry crashTelemetry = CreateCrashTelemetry(exception, exitType, isUnhandled, isCritical);

            // Initialize here because the process is about to die — this may be the only chance to set up telemetry.
            // A worker, task host or RAR node has no session, because only the process that builds in-process starts one,
            // and nothing else can report its crash. In MSBuild.exe a session is created just for the crash.
            // Before Main ran (the MSBuildApp static constructor) or in a task AppDomain (separate static state),
            // and in a process that MSBuild is hosted in, the host's session is used if there is one.
            TelemetryManager.Instance.InitializeForCrash();

            using IActivity? activity = TelemetryManager.Instance.DefaultActivitySource
                ?.StartActivity(TelemetryConstants.Crash);

            activity?.SetTags(crashTelemetry);

            PostFaultEvent(crashTelemetry);
        }
        catch
        {
            // Best effort: telemetry must never cause a secondary failure.
        }

        // The process is about to terminate without reaching Main's telemetry shutdown.
        // This is bounded by the shutdown budget (MSBUILD_TELEMETRY_SHUTDOWN_TIMEOUT_MS) like any other shutdown.
        try
        {
            TelemetryManager.Instance.Dispose();
        }
        catch
        {
            // Best effort: telemetry must never cause a secondary failure.
        }
    }

    /// <summary>
    /// Flushes any pending crash telemetry via the telemetry manager.
    /// The caller (the entry process, the server node, or <c>BuildManager</c>) normally started the session already.
    /// In MSBuild.exe, a process that has none, such as a worker, task host or RAR node, or the thin client of a build server,
    /// starts one just for the crash (see <see cref="TelemetryManager.InitializeForCrash"/>), and <c>Main</c> shuts it down on exit.
    /// A process that MSBuild is hosted in only uses a session that already exists.
    /// </summary>
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static void FlushCrashTelemetry()
    {
        try
        {
            CrashTelemetry? crashTelemetry = KnownTelemetry.CrashTelemetry;
            if (crashTelemetry is null)
            {
                return;
            }

            KnownTelemetry.CrashTelemetry = null;

            // Only MSBuild.exe itself creates a session here. Creating one when tests call MSBuildApp.Execute()
            // in-process would create a VS telemetry session, causing environment variable side effects.
            if (TelemetryManager.IsStandaloneProcess)
            {
                TelemetryManager.Instance.InitializeForCrash();
            }

            using IActivity? activity = TelemetryManager.Instance.DefaultActivitySource
                ?.StartActivity(TelemetryConstants.Crash);
            activity?.SetTags(crashTelemetry);

            PostFaultEvent(crashTelemetry);
        }
        catch
        {
            // Best effort: telemetry must never cause a secondary failure.
        }
    }

    /// <summary>
    /// Posts a <c>FaultEvent</c> to the VS telemetry session so that crashes
    /// appear in Prism fault dashboards alongside other VS component faults.
    /// Only available on .NET Framework where the VS Telemetry SDK is loaded.
    /// See https://dev.azure.com/devdiv/DevDiv/_wiki/wikis/DevDiv.wiki/1022/FaultEvent-instrumentation-guide
    /// </summary>
#if NETFRAMEWORK
    [MethodImpl(MethodImplOptions.NoInlining)]
#endif
    private static void PostFaultEvent(CrashTelemetry crashTelemetry)
    {
#if NETFRAMEWORK
        try
        {
            if (crashTelemetry.Exception is null || TelemetryManager.IsOptOut())
            {
                return;
            }

            string eventName = $"{TelemetryConstants.EventPrefix}{TelemetryConstants.Crash}";
            string description = $"{crashTelemetry.ExitType}: {crashTelemetry.ExceptionType}";
            var sanitizedException = new SanitizedException(crashTelemetry.Exception!);
            var faultEvent = new FaultEvent(eventName, description, sanitizedException);

            faultEvent.Properties[$"{TelemetryConstants.PropertyPrefix}ExitType"] = crashTelemetry.ExitType.ToString();
            faultEvent.Properties[$"{TelemetryConstants.PropertyPrefix}CrashOrigin"] = crashTelemetry.CrashOrigin.ToString();

            if (crashTelemetry.CrashOriginNamespace is not null)
            {
                faultEvent.Properties[$"{TelemetryConstants.PropertyPrefix}CrashOriginNamespace"] = crashTelemetry.CrashOriginNamespace;
            }

            if (crashTelemetry.StackHash is not null)
            {
                faultEvent.Properties[$"{TelemetryConstants.PropertyPrefix}StackHash"] = crashTelemetry.StackHash;
            }

            TelemetryService.DefaultSession?.PostEvent(faultEvent);
        }
        catch
        {
            // Best effort: fault telemetry must never cause a secondary failure.
        }
#endif
    }

    /// <summary>
    /// Exception wrapper passed to VS Telemetry's <c>FaultEvent</c> that carries only the exception type
    /// instead of the message, which can contain customer data, and a stack trace with file paths removed.
    /// </summary>
    internal sealed class SanitizedException : Exception
    {
        private readonly string? _sanitizedStackTrace;

        public SanitizedException(Exception original)
            : base(original.GetType().FullName,
                   original.InnerException is not null ? new SanitizedException(original.InnerException) : null)
        {
            _sanitizedStackTrace = original.StackTrace is not null
                ? CrashTelemetry.SanitizeFilePathsInText(original.StackTrace)
                : null;
        }

        public override string? StackTrace => _sanitizedStackTrace;
    }

    private static CrashTelemetry CreateCrashTelemetry(
        Exception exception,
        CrashExitType exitType,
        bool isUnhandled,
        bool isCritical)
    {
        CrashTelemetry crashTelemetry = new();
        crashTelemetry.PopulateFromException(exception);
        crashTelemetry.ExitType = exitType;
        crashTelemetry.IsCritical = isCritical;
        crashTelemetry.IsUnhandled = isUnhandled;
        return crashTelemetry;
    }

    /// <summary>
    /// Emits a pre-populated <see cref="CrashTelemetry"/> for an EndBuild hang.
    /// Called periodically from timed wait loops so that diagnostics are available
    /// even if the hang never resolves (crash telemetry in the finally block would be unreachable).
    /// The caller is responsible for populating the <see cref="CrashTelemetry"/> with
    /// all relevant hang diagnostic data.
    /// Only <c>BuildManager</c> emits these, in the process that owns the build (the entry process or the server node),
    /// which already has a session: its own, or the host's. A worker, task host or RAR node never emits hang diagnostics.
    /// </summary>
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static void EmitEndBuildHangDiagnostics(CrashTelemetry crashTelemetry)
    {
        try
        {
            TelemetryManager.Instance.Initialize(crashTelemetry.IsStandaloneExecution ?? false);

            using IActivity? activity = TelemetryManager.Instance.DefaultActivitySource
                ?.StartActivity(TelemetryConstants.Crash);
            activity?.SetTags(crashTelemetry);
        }
        catch
        {
            // Best effort: diagnostic telemetry must never cause a secondary failure.
        }
    }
}