File: Telemetry\TelemetryManager.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.

#if NETFRAMEWORK
using System.Threading.Tasks;
using Microsoft.VisualStudio.Telemetry;
#endif

using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;

namespace Microsoft.Build.Framework.Telemetry
{
    /// <summary>
    /// Manages telemetry collection and reporting for MSBuild.
    /// This class provides a centralized way to initialize, configure, and manage telemetry sessions.
    /// </summary>
    /// <remarks>
    /// The TelemetryManager is a singleton that handles both standalone and integrated telemetry scenarios.
    /// On .NET Framework, it integrates with Visual Studio telemetry services.
    /// On .NET Core it provides a lightweight telemetry implementation through exposing an activity source.
    /// </remarks>
    internal class TelemetryManager
    {
        /// <summary>
        /// Lock object for thread-safe initialization and disposal.
        /// </summary>
        private static readonly LockType s_lock = new();

        private static bool s_initialized;
        private static bool s_disposed;

        // Signaled when the shutdown started by the first Dispose() call has finished, so that a later call
        // can wait for it instead of returning while the session is still being saved or uploaded.
        private static readonly ManualResetEventSlim s_shutdownCompleted = new(initialState: false);
        private static Stopwatch? s_shutdownStopwatch;

        /// <summary>
        /// Indicates whether the telemetry infrastructure has been torn down, or its teardown has started.
        /// Exposed for TESTING purposes.
        /// </summary>
        internal static bool IsDisposed => s_disposed;

        /// <summary>
        /// Indicates that this process is MSBuild.exe itself, started from the command line (as opposed to a process that hosts MSBuild,
        /// such as Visual Studio or a test host), so no host owns a telemetry session for it and MSBuild may create its own.
        /// Only <c>MSBuildApp.Main</c> sets it. It decides whether a process that crashes without having started a session,
        /// such as a worker, task host or RAR node, creates a session just to report the crash. See <see cref="InitializeForCrash"/>.
        /// </summary>
        internal static bool IsStandaloneProcess { get; set; }

        private TelemetryManager()
        {
        }

        /// <summary>
        /// Optional activity source for MSBuild or other telemetry usage.
        /// </summary>
        public MSBuildActivitySource? DefaultActivitySource { get; private set; }

        public static TelemetryManager Instance { get; } = new TelemetryManager();

        /// <summary>
        /// Initializes the telemetry manager with the specified configuration.
        /// </summary>
        /// <param name="isStandalone">
        /// Indicates whether MSBuild is running in standalone mode (e.g., MSBuild.exe directly invoked)
        /// versus integrated mode (e.g., running within Visual Studio or dotnet CLI).
        /// When <c>true</c>, creates and manages its own telemetry session on .NET Framework.
        /// </param>
        [MethodImpl(MethodImplOptions.NoInlining)]
        public void Initialize(bool isStandalone)
        {
            lock (s_lock)
            {
                // A session created after Dispose would never be shut down.
                if (s_initialized || s_disposed)
                {
                    return;
                }

                s_initialized = true;

                if (IsOptOut())
                {
                    WriteDiagnostic("opted out.");
                    return;
                }

                TryInitializeTelemetry(isStandalone);
            }
        }

        /// <summary>
        /// Initializes telemetry on a path that reports a crash, in a process that may not have started a session up front:
        /// only the process that builds in-process (the entry process or the server node) does so, so a worker, task host or RAR node
        /// has none when it crashes, and nothing else can report the crash for it.
        /// In MSBuild.exe (<see cref="IsStandaloneProcess"/>) this creates a session just for the crash, which the shutdown at the end of
        /// <c>Main</c>, or <see cref="CrashTelemetryRecorder.RecordAndFlushCrashTelemetry"/>, saves or uploads within the shutdown budget.
        /// In a process that MSBuild is hosted in, it uses the host's session, and does nothing if there is none.
        /// Does nothing when a session was already started.
        /// </summary>
        public void InitializeForCrash() => Initialize(IsStandaloneProcess);

        /// <summary>
        /// Resets the TelemetryManager state for TESTING purposes.
        /// </summary>
        internal static void ResetForTest()
        {
            lock (s_lock)
            {
                s_initialized = false;
                s_disposed = false;
                s_shutdownStopwatch = null;
                s_shutdownCompleted.Reset();
                IsStandaloneProcess = false;
                Instance.DefaultActivitySource = null;
            }
        }

        /// <summary>
        /// Initializes MSBuild telemetry.
        /// This method is deliberately not inlined to ensure
        /// the Telemetry related assemblies are only loaded when this method is called,
        /// allowing the calling code to catch assembly loading exceptions.
        /// </summary>
        [MethodImpl(MethodImplOptions.NoInlining)]
        private void TryInitializeTelemetry(bool isStandalone)
        {
            try
            {
#if NETFRAMEWORK
                DefaultActivitySource = VsTelemetryInitializer.Initialize(isStandalone);
#else
                DefaultActivitySource = new MSBuildActivitySource(TelemetryConstants.DefaultActivitySourceNamespace);
#endif
            }
            catch (Exception ex) when (!ExceptionHandling.IsCriticalException(ex))
            {
                // Telemetry is best effort and must never fail a build.
                // Microsoft.VisualStudio.Telemetry or System.Diagnostics.DiagnosticSource might not be available outside of VS or dotnet
                // (FileNotFoundException, FileLoadException, TypeLoadException) - this is expected in standalone application scenarios
                // (when MSBuild.exe is invoked directly). The telemetry stack itself can also throw, for example when the machine is
                // configured to opt out of Visual Studio telemetry, so any non-critical failure simply disables telemetry for this process.
                DefaultActivitySource = null;
                WriteDiagnostic("initialization failed, telemetry is disabled for this process: " + ex);
            }
        }

        /// <summary>
        /// Shuts telemetry down, saving or uploading the pending events of a session that this process owns within the shutdown budget
        /// (<c>MSBUILD_TELEMETRY_SHUTDOWN_TIMEOUT_MS</c>). It can be called more than once, for example by <c>Main</c> and by the
        /// unhandled exception handler. A later call waits for the shutdown the first one started, within what is left of the budget,
        /// so that the process doesn't exit under it.
        /// </summary>
        public void Dispose() => Dispose(ShutdownSession, Traits.Instance.TelemetryShutdownTimeoutMs);

        /// <summary>
        /// Exposed for deterministic testing without creating a real telemetry session.
        /// </summary>
        /// <param name="shutdown">Saves or uploads the pending events. Only the first call to Dispose runs it.</param>
        /// <param name="budgetMs">The time, counted from the start of the shutdown, that a later call waits for it to finish.</param>
        internal void Dispose(Action shutdown, int budgetMs)
        {
            Stopwatch shutdownStopwatch;
            bool isFirstCall;

            lock (s_lock)
            {
                isFirstCall = !s_disposed;

                if (isFirstCall)
                {
                    s_disposed = true;
                    s_shutdownStopwatch = Stopwatch.StartNew();

                    // Nothing may use the activity source once the underlying session is gone.
                    DefaultActivitySource = null;
                }

                shutdownStopwatch = s_shutdownStopwatch!;
            }

            if (!isFirstCall)
            {
                s_shutdownCompleted.Wait((int)Math.Max(0, budgetMs - shutdownStopwatch.ElapsedMilliseconds));
                return;
            }

            try
            {
                shutdown();
            }
            catch (Exception ex) when (!ExceptionHandling.IsCriticalException(ex))
            {
                // Telemetry is best effort and must never fail a build.
                // The Visual Studio telemetry assembly may never have been loaded (FileNotFoundException,
                // FileLoadException, TypeLoadException), and disposing the session can itself throw when the
                // telemetry stack was not fully started - for example when telemetry is opted out machine wide.
                // Critical exceptions still propagate because the process is not safe to continue.
                WriteDiagnostic("shutdown failed: " + ex);
            }
            finally
            {
                s_shutdownCompleted.Set();
            }
        }

        private static void ShutdownSession()
        {
#if NETFRAMEWORK
            DisposeVsTelemetry();
#endif
        }

        /// <summary>
        /// Determines if the user has explicitly opted out of telemetry.
        /// </summary>
        /// <remarks>
        /// The .NET SDK opt-out (<c>DOTNET_CLI_TELEMETRY_OPTOUT</c>) applies in every process, including MSBuild running inside Visual Studio,
        /// because this one check gates both the session and the collection of build data in <c>BuildManager.BeginBuild</c>.
        /// A user who opted out of .NET telemetry stays opted out wherever MSBuild runs, without a per-host list of exceptions.
        /// </remarks>
        internal static bool IsOptOut() =>
#if NETFRAMEWORK
            Traits.Instance.FrameworkTelemetryOptOut || Traits.Instance.SdkTelemetryOptOut;
#else
            Traits.Instance.SdkTelemetryOptOut;
#endif

        /// <summary>
        /// Writes a message to the standard error stream when MSBUILD_TELEMETRY_DIAGNOSTICS is set.
        /// </summary>
        internal static void WriteDiagnostic(string message)
        {
            if (Traits.Instance.TelemetryDiagnostics)
            {
                try
                {
                    Console.Error.WriteLine("MSBuild telemetry: " + message);
                }
                catch (Exception ex) when (!ExceptionHandling.IsCriticalException(ex))
                {
                    // Console may not be available (e.g. redirected to a broken pipe).
                }
            }
        }

#if NETFRAMEWORK
        [MethodImpl(MethodImplOptions.NoInlining)]
        private static void DisposeVsTelemetry() => VsTelemetryInitializer.Dispose();
#endif
    }

#if NETFRAMEWORK
    internal static class VsTelemetryInitializer
    {
        // Telemetry API key for Visual Studio telemetry service.
        private const string CollectorApiKey = "f3e86b4023cc43f0be495508d51f588a-f70d0e59-0fb0-4473-9f19-b4024cc340be-7296";

        // Store as object to avoid type reference at class load time
        private static object? s_telemetrySession;
        private static object? s_activitySource;
        private static bool s_ownsSession = false;

        // The upload gets four fifths of the shutdown budget. The rest is kept for saving the events locally when it does not complete.
        private const int SaveReserveDivisor = 5;

        internal enum SessionShutdownResult
        {
            /// <summary>The pending events were uploaded, or saved when no upload was requested, within the budget.</summary>
            Completed,

            /// <summary>The upload failed or ran out of time, and the pending events were saved so that a later process can upload them.</summary>
            SavedInsteadOfUploaded,

            /// <summary>Nothing completed within the budget, so the pending events may be lost.</summary>
            TimedOut,
        }

        private enum TaskOutcome
        {
            Succeeded,
            Failed,
            StillRunning,
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public static MSBuildActivitySource Initialize(bool isStandalone)
        {
            TelemetrySession? session;
            if (isStandalone)
            {
                session = TelemetryService.CreateAndGetDefaultSession(CollectorApiKey);
            }
            else
            {
                session = TelemetryService.DefaultSession;
            }

            // Record ownership before configuring the session so shutdown can clean up even if the
            // telemetry stack throws partway through initialization.
            s_telemetrySession = session;
            s_ownsSession = isStandalone && session is not null;

            // The telemetry stack can decline to create a session, for example when telemetry is disabled
            // machine wide.
            if (isStandalone && session is not null)
            {
                session.UseVsIsOptedIn();
                session.Start();
            }

            if (Traits.Instance.TelemetryDiagnostics)
            {
                string state = session is null
                    ? "no session is available"
                    : $"{(isStandalone ? "owned" : "host")} session, {(session.IsOptedIn ? "opted in" : "not opted in, events will not be sent")}";
                TelemetryManager.WriteDiagnostic(
                    $"initialized ({state}, CI: {BuildEnvironmentState.IsAutomatedEnvironment()}) using {typeof(TelemetrySession).Assembly.Location}.");
            }

            MSBuildActivitySource activitySource = new(session);
            s_activitySource = activitySource;
            return activitySource;
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        public static void Dispose()
        {
            object? telemetrySession = s_telemetrySession;
            object? activitySource = s_activitySource;
            bool ownsSession = s_ownsSession;

            // Clear the state first so that a failure while disposing the session cannot leave a stale
            // session behind, and so that a subsequent disposal attempt is a no-op.
            s_telemetrySession = null;
            s_activitySource = null;
            s_ownsSession = false;

            // Checked without telemetry types, so that processes without a session do not load the telemetry assembly.
            if (ownsSession && telemetrySession is not null)
            {
                DisposeOwnedSession(telemetrySession, activitySource);
            }
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        private static void DisposeOwnedSession(object telemetrySession, object? activitySource)
        {
            TelemetrySession session = (TelemetrySession)telemetrySession;

            bool isAutomatedEnvironment = BuildEnvironmentState.IsAutomatedEnvironment();
            bool hasStartedEvents = activitySource is MSBuildActivitySource { HasStartedActivities: true };
            bool isOptedIn = IsOptedIn(session);
            bool upload = ShouldUploadOnShutdown(isAutomatedEnvironment, hasStartedEvents, isOptedIn);
            int timeoutMs = Traits.Instance.TelemetryShutdownTimeoutMs;
            Stopwatch stopwatch = Stopwatch.StartNew();

            SessionShutdownResult result = DisposeOwnedSession(
                upload,
                timeoutMs,
                cancellationToken => session.DisposeToNetworkAsync(cancellationToken),
                session.Dispose);

            string outcome = result switch
            {
                SessionShutdownResult.Completed => "completed",
                SessionShutdownResult.SavedInsteadOfUploaded => "did not complete, the pending events were saved for a later upload",
                _ => "timed out, pending events may be lost",
            };

            TelemetryManager.WriteDiagnostic(
                $"{(upload ? "upload" : "save")} {outcome} after {stopwatch.ElapsedMilliseconds} ms, budget {timeoutMs} ms (CI: {isAutomatedEnvironment}, events started: {hasStartedEvents}, opted in: {isOptedIn}).");
        }

        private static bool IsOptedIn(TelemetrySession session)
        {
            try
            {
                return session.IsOptedIn;
            }
            catch (Exception ex) when (!ExceptionHandling.IsCriticalException(ex))
            {
                // Saving the events locally is the safe choice when the consent cannot be read.
                TelemetryManager.WriteDiagnostic("reading the consent failed: " + ex);
                return false;
            }
        }

        /// <summary>
        /// Decides whether exiting should wait for the pending events to be uploaded, rather than only save them for a later upload.
        /// An ephemeral CI agent can be discarded before another process uploads what was saved, so there the upload is worth a bounded wait.
        /// The trigger is the shared automated-environment detection, which the terminal logger uses too: a false positive, such as a
        /// generic variable like <c>BUILD_ID</c> on a developer machine, costs at most the shutdown budget and then a local save,
        /// while a false negative loses the events of an agent that is discarded.
        /// Waiting is pointless when the session is not opted in, because nothing is sent, or when the process started no events.
        /// </summary>
        internal static bool ShouldUploadOnShutdown(bool isAutomatedEnvironment, bool hasStartedEvents, bool isOptedIn)
            => isAutomatedEnvironment && hasStartedEvents && isOptedIn;

        /// <summary>
        /// Saves or uploads the pending events of an owned telemetry session, taking <paramref name="timeoutMs"/> in total at most.
        /// Exposed for deterministic testing without creating a real Visual Studio telemetry session.
        /// </summary>
        /// <param name="upload">
        /// <c>true</c> to wait for the events to be uploaded. The upload gets most of the budget. If it fails or does not complete in time,
        /// it is canceled and, only once it has stopped, the session is disposed to save the events so that a later process can upload them.
        /// The two never run at the same time. <c>false</c> only saves the events.
        /// </param>
        /// <param name="timeoutMs">The total budget. Zero doesn't wait.</param>
        /// <param name="disposeToNetworkAsync">Uploads the pending events and disposes the session. It has to honor the cancellation token.</param>
        /// <param name="dispose">Saves the pending events and disposes the session.</param>
        internal static SessionShutdownResult DisposeOwnedSession(
            bool upload,
            int timeoutMs,
            Func<CancellationToken, Task> disposeToNetworkAsync,
            Action dispose)
        {
            if (!upload)
            {
                return RunWithinBudget(dispose, timeoutMs) ? SessionShutdownResult.Completed : SessionShutdownResult.TimedOut;
            }

            Stopwatch stopwatch = Stopwatch.StartNew();

            // Deliberately not disposed: an upload that outlives the budget can still be using the token while the process exits,
            // and a source without timers or linked tokens holds nothing that outlives the process.
#pragma warning disable CA2000 // Dispose objects before losing scope
            CancellationTokenSource cancellation = new();
#pragma warning restore CA2000 // Dispose objects before losing scope

            // Disposing can block on the network, so the exiting process abandons the thread after the budget.
            Task uploadTask = Task.Run(() => disposeToNetworkAsync(cancellation.Token));

            TaskOutcome outcome = Wait(uploadTask, timeoutMs - (timeoutMs / SaveReserveDivisor), out Exception? failure);

            if (outcome == TaskOutcome.Failed)
            {
                TelemetryManager.WriteDiagnostic("upload failed: " + failure);
            }
            else if (outcome == TaskOutcome.StillRunning)
            {
                // The upload saves the pending events before it transmits them, and stops when canceled.
                cancellation.Cancel();
                outcome = Wait(uploadTask, RemainingMs(timeoutMs, stopwatch), out _);
            }

            switch (outcome)
            {
                case TaskOutcome.Succeeded:
                    return SessionShutdownResult.Completed;
                case TaskOutcome.StillRunning:
                    // Disposing the session now would race with the upload that is still using it.
                    return SessionShutdownResult.TimedOut;
                default:
                    return RunWithinBudget(dispose, RemainingMs(timeoutMs, stopwatch))
                        ? SessionShutdownResult.SavedInsteadOfUploaded
                        : SessionShutdownResult.TimedOut;
            }
        }

        private static bool RunWithinBudget(Action action, int timeoutMs) => Task.Run(action).Wait(timeoutMs);

        private static int RemainingMs(int timeoutMs, Stopwatch stopwatch) => (int)Math.Max(0, timeoutMs - stopwatch.ElapsedMilliseconds);

        private static TaskOutcome Wait(Task task, int timeoutMs, out Exception? failure)
        {
            failure = null;

            try
            {
                return task.Wait(timeoutMs) ? TaskOutcome.Succeeded : TaskOutcome.StillRunning;
            }
            catch (AggregateException ex)
            {
                // The task faulted or was canceled, which means it has stopped.
                failure = ex.GetBaseException();
                return TaskOutcome.Failed;
            }
        }
    }
#endif
}