From 309aea6c4a1b9b0c05645c04dbaee574bb0d121a Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 29 Jan 2026 16:21:20 -0800 Subject: [PATCH 01/59] add telemetry - initial phase --- Directory.Packages.props | 5 + src/Installer/dotnetup/CommandBase.cs | 103 +++++++- .../DefaultInstall/DefaultInstallCommand.cs | 4 +- .../ElevatedAdminPathCommand.cs | 4 +- .../dotnetup/Commands/List/ListCommand.cs | 4 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 4 +- src/Installer/dotnetup/Program.cs | 49 +++- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 247 ++++++++++++++++++ .../dotnetup/Telemetry/ErrorCodeMapper.cs | 60 +++++ .../Telemetry/TelemetryCommonProperties.cs | 111 ++++++++ .../dotnetup/Telemetry/TelemetryEventData.cs | 56 ++++ src/Installer/dotnetup/dotnetup.csproj | 5 + 12 files changed, 633 insertions(+), 19 deletions(-) create mode 100644 src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs create mode 100644 src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs create mode 100644 src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs create mode 100644 src/Installer/dotnetup/Telemetry/TelemetryEventData.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f5fee8a9ebea..610daebed340 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -118,6 +118,11 @@ + + + + + diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index e1b27c8efc9f..085b349e70fe 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -4,26 +4,115 @@ using System; using System.Collections.Generic; using System.CommandLine; +using System.Diagnostics; using System.Text; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; namespace Microsoft.DotNet.Tools.Bootstrapper; +/// +/// Base class for all dotnetup commands with automatic telemetry. +/// Uses the template method pattern to wrap command execution with telemetry. +/// public abstract class CommandBase { protected ParseResult _parseResult; + private Activity? _commandActivity; protected CommandBase(ParseResult parseResult) { _parseResult = parseResult; - //ShowHelpOrErrorIfAppropriate(parseResult); } - //protected CommandBase() { } + /// + /// Executes the command with automatic telemetry tracking. + /// + /// The exit code of the command. + public int Execute() + { + var commandName = GetCommandName(); + _commandActivity = DotnetupTelemetry.Instance.StartCommand(commandName); + var stopwatch = Stopwatch.StartNew(); + + try + { + var exitCode = ExecuteCore(); + + stopwatch.Stop(); + _commandActivity?.SetTag("exit.code", exitCode); + _commandActivity?.SetStatus(exitCode == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); + + // Post completion event for metrics + DotnetupTelemetry.Instance.PostEvent("command/completed", new Dictionary + { + ["command"] = commandName, + ["exit_code"] = exitCode.ToString(), + ["success"] = (exitCode == 0).ToString() + }, new Dictionary + { + ["duration_ms"] = stopwatch.Elapsed.TotalMilliseconds + }); - //protected virtual void ShowHelpOrErrorIfAppropriate(ParseResult parseResult) - //{ - // parseResult.ShowHelpOrErrorIfAppropriate(); - //} + return exitCode; + } + catch (Exception ex) + { + stopwatch.Stop(); + DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); + + // Post failure event + var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); + var props = new Dictionary + { + ["command"] = commandName, + ["error_type"] = errorInfo.ErrorType + }; + if (errorInfo.StatusCode.HasValue) + { + props["http_status"] = errorInfo.StatusCode.Value.ToString(); + } + if (errorInfo.HResult.HasValue) + { + props["hresult"] = errorInfo.HResult.Value.ToString(); + } + DotnetupTelemetry.Instance.PostEvent("command/failed", props, new Dictionary + { + ["duration_ms"] = stopwatch.Elapsed.TotalMilliseconds + }); + + throw; + } + finally + { + _commandActivity?.Dispose(); + } + } - public abstract int Execute(); + /// + /// Implement this method to provide the command's core logic. + /// + /// The exit code of the command. + protected abstract int ExecuteCore(); + + /// + /// Gets the command name for telemetry purposes. + /// Override to provide a custom name. + /// + /// The command name (e.g., "sdk/install"). + protected virtual string GetCommandName() + { + // Default: derive from class name (SdkInstallCommand -> "sdkinstall") + var name = GetType().Name; + return name.Replace("Command", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant(); + } + + /// + /// Adds a tag to the current command activity. + /// + /// The tag key. + /// The tag value. + protected void SetCommandTag(string key, object? value) + { + _commandActivity?.SetTag(key, value); + } } diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index e9c5622d01c5..b48b52237d19 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -16,7 +16,9 @@ public DefaultInstallCommand(ParseResult result, IDotnetInstallManager? dotnetIn _installRootManager = new InstallRootManager(dotnetInstaller); } - public override int Execute() + protected override string GetCommandName() => "defaultinstall"; + + protected override int ExecuteCore() { return _installType.ToLowerInvariant() switch { diff --git a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs index c55fe7aae5a1..09c9e161af20 100644 --- a/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs +++ b/src/Installer/dotnetup/Commands/ElevatedAdminPath/ElevatedAdminPathCommand.cs @@ -24,7 +24,9 @@ void Log(string message) File.AppendAllText(_outputFile, message + Environment.NewLine); } - public override int Execute() + protected override string GetCommandName() => "elevatedadminpath"; + + protected override int ExecuteCore() { // This command only works on Windows if (!OperatingSystem.IsWindows()) diff --git a/src/Installer/dotnetup/Commands/List/ListCommand.cs b/src/Installer/dotnetup/Commands/List/ListCommand.cs index 4c6a05195263..26a827837d39 100644 --- a/src/Installer/dotnetup/Commands/List/ListCommand.cs +++ b/src/Installer/dotnetup/Commands/List/ListCommand.cs @@ -20,7 +20,9 @@ public ListCommand(ParseResult parseResult) : base(parseResult) _skipVerification = parseResult.GetValue(ListCommandParser.NoVerifyOption); } - public override int Execute() + protected override string GetCommandName() => "list"; + + protected override int ExecuteCore() { var installations = InstallationLister.GetInstallations(verify: !_skipVerification); diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs index 4dc776de7bf9..7ad9e6865cf1 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -27,7 +27,9 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private InstallRootManager? _installRootManager; private InstallRootManager InstallRootManager => _installRootManager ??= new InstallRootManager(_dotnetInstaller); - public override int Execute() + protected override string GetCommandName() => "sdk/install"; + + protected override int ExecuteCore() { var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); diff --git a/src/Installer/dotnetup/Program.cs b/src/Installer/dotnetup/Program.cs index d56acd2e8099..4a049e2adb1c 100644 --- a/src/Installer/dotnetup/Program.cs +++ b/src/Installer/dotnetup/Program.cs @@ -1,16 +1,49 @@ -using Microsoft.DotNet.Tools.Bootstrapper; +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.DotNet.Tools.Bootstrapper +using System.Diagnostics; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +internal class DotnetupProgram { - internal class DotnetupProgram + public static int Main(string[] args) { - public static int Main(string[] args) + // Handle --debug flag using the standard .NET SDK pattern + // This is DEBUG-only and removes the --debug flag from args + DotnetupDebugHelper.HandleDebugSwitch(ref args); + + // Start root activity for the entire process + using var rootActivity = DotnetupTelemetry.Instance.Enabled + ? DotnetupTelemetry.CommandSource.StartActivity("dotnetup", ActivityKind.Internal) + : null; + + try { - // Handle --debug flag using the standard .NET SDK pattern - // This is DEBUG-only and removes the --debug flag from args - DotnetupDebugHelper.HandleDebugSwitch(ref args); + var result = Parser.Invoke(args); + rootActivity?.SetTag("exit.code", result); + rootActivity?.SetStatus(result == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); + return result; + } + catch (Exception ex) + { + // Catch-all for unhandled exceptions + DotnetupTelemetry.Instance.RecordException(rootActivity, ex); + rootActivity?.SetTag("exit.code", 1); - return Parser.Invoke(args); + // Re-throw to preserve original behavior (or handle as appropriate) + Console.Error.WriteLine($"Error: {ex.Message}"); +#if DEBUG + Console.Error.WriteLine(ex.StackTrace); +#endif + return 1; + } + finally + { + // Ensure telemetry is flushed before exit + DotnetupTelemetry.Instance.Flush(); + DotnetupTelemetry.Instance.Dispose(); } } } diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs new file mode 100644 index 000000000000..f4c111f3b117 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -0,0 +1,247 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.DotNet.Cli.Utils; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Singleton telemetry manager for dotnetup. +/// Uses OpenTelemetry with Azure Monitor exporter (AOT compatible). +/// +public sealed class DotnetupTelemetry : IDisposable +{ + private static readonly Lazy s_instance = new(() => new DotnetupTelemetry()); + + /// + /// Gets the singleton instance of DotnetupTelemetry. + /// + public static DotnetupTelemetry Instance => s_instance.Value; + + /// + /// ActivitySource for command-level telemetry. + /// + public static readonly ActivitySource CommandSource = new( + "Microsoft.Dotnet.Bootstrapper", + GetVersion()); + + /// + /// Connection string for Application Insights (same as dotnet CLI). + /// + private const string ConnectionString = "InstrumentationKey=74cc1c9e-3e6e-4d05-b3fc-dde9101d0254"; + + /// + /// Environment variable to opt out of telemetry. + /// + private const string TelemetryOptOutEnvVar = "DOTNET_CLI_TELEMETRY_OPTOUT"; + + private readonly TracerProvider? _tracerProvider; + private readonly string _sessionId; + private bool _disposed; + + /// + /// Gets whether telemetry is enabled. + /// + public bool Enabled { get; } + + /// + /// Gets the current session ID. + /// + public string SessionId => _sessionId; + + private DotnetupTelemetry() + { + _sessionId = Guid.NewGuid().ToString(); + + // Check opt-out (same env var as SDK) + var optOutValue = Environment.GetEnvironmentVariable(TelemetryOptOutEnvVar); + Enabled = !string.Equals(optOutValue, "1", StringComparison.Ordinal) && + !string.Equals(optOutValue, "true", StringComparison.OrdinalIgnoreCase); + + if (!Enabled) + { + return; + } + + try + { + var builder = Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService( + serviceName: "dotnetup", + serviceVersion: GetVersion()) + .AddAttributes(TelemetryCommonProperties.GetCommonAttributes(_sessionId))) + .AddSource("Microsoft.Dotnet.Bootstrapper") + .AddSource("Microsoft.Dotnet.Installation"); // Library's ActivitySource + + // Add Azure Monitor exporter + builder.AddAzureMonitorTraceExporter(o => + { + o.ConnectionString = ConnectionString; + }); + +#if DEBUG + // Console exporter for local debugging + if (Environment.GetEnvironmentVariable("DOTNETUP_TELEMETRY_DEBUG") == "1") + { + builder.AddConsoleExporter(); + } +#endif + + _tracerProvider = builder.Build(); + } + catch (Exception) + { + // Telemetry should never crash the app + Enabled = false; + } + } + + /// + /// Starts a command activity with the given name. + /// + /// The name of the command (e.g., "sdk/install"). + /// The started Activity, or null if telemetry is disabled. + public Activity? StartCommand(string commandName) + { + if (!Enabled) + { + return null; + } + + var activity = CommandSource.StartActivity($"command/{commandName}", ActivityKind.Internal); + activity?.SetTag("command.name", commandName); + activity?.SetTag("caller", "dotnetup"); + activity?.SetTag("session.id", _sessionId); + return activity; + } + + /// + /// Records an exception on the given activity. + /// + /// The activity to record the exception on. + /// The exception to record. + /// Optional error code override. + public void RecordException(Activity? activity, Exception ex, string? errorCode = null) + { + if (activity == null || !Enabled) + { + return; + } + + var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); + + activity.SetStatus(ActivityStatusCode.Error, ex.Message); + activity.SetTag("error.type", errorInfo.ErrorType); + activity.SetTag("error.code", errorCode ?? errorInfo.ErrorType); + + if (errorInfo.StatusCode.HasValue) + { + activity.SetTag("error.http_status", errorInfo.StatusCode.Value); + } + + if (errorInfo.HResult.HasValue) + { + activity.SetTag("error.hresult", errorInfo.HResult.Value); + } + + activity.RecordException(ex); + } + + /// + /// Posts a custom telemetry event. + /// + /// The name of the event. + /// Optional string properties. + /// Optional numeric measurements. + public void PostEvent( + string eventName, + Dictionary? properties = null, + Dictionary? measurements = null) + { + if (!Enabled) + { + return; + } + + using var activity = CommandSource.StartActivity(eventName, ActivityKind.Internal); + if (activity == null) + { + return; + } + + activity.SetTag("session.id", _sessionId); + + if (properties != null) + { + foreach (var (key, value) in properties) + { + activity.SetTag(key, value); + } + } + + if (measurements != null) + { + foreach (var (key, value) in measurements) + { + activity.SetTag(key, value); + } + } + } + + /// + /// Posts an install completed event. + /// + public void PostInstallEvent(InstallEventData data) + { + PostEvent("install/completed", new Dictionary + { + ["component"] = data.Component, + ["version"] = data.Version, + ["previous_version"] = data.PreviousVersion ?? string.Empty, + ["was_update"] = data.WasUpdate.ToString(), + ["install_root_hash"] = TelemetryCommonProperties.HashPath(data.InstallRoot) + }, new Dictionary + { + ["download_ms"] = data.DownloadDuration.TotalMilliseconds, + ["extract_ms"] = data.ExtractionDuration.TotalMilliseconds, + ["archive_bytes"] = data.ArchiveSizeBytes + }); + } + + /// + /// Flushes any pending telemetry. + /// + public void Flush() + { + _tracerProvider?.ForceFlush(); + } + + /// + /// Disposes the telemetry provider. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _tracerProvider?.Dispose(); + _disposed = true; + } + + private static string GetVersion() + { + return typeof(DotnetupTelemetry).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? "0.0.0"; + } +} diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs new file mode 100644 index 000000000000..c44ee948478c --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Error info extracted from an exception for telemetry. +/// +/// The exception type name. +/// HTTP status code if applicable. +/// Win32 HResult if applicable. +/// Additional context like file path. +public sealed record ExceptionErrorInfo( + string ErrorType, + int? StatusCode = null, + int? HResult = null, + string? Details = null); + +/// +/// Maps exceptions to error info for telemetry. +/// +public static class ErrorCodeMapper +{ + /// + /// Extracts error info from an exception. + /// + /// The exception to analyze. + /// Error info with type name and contextual details. + public static ExceptionErrorInfo GetErrorInfo(Exception ex) + { + // Unwrap single-inner AggregateExceptions + if (ex is AggregateException { InnerExceptions.Count: 1 } aggEx) + { + return GetErrorInfo(aggEx.InnerExceptions[0]); + } + + var typeName = ex.GetType().Name; + + return ex switch + { + HttpRequestException httpEx => new ExceptionErrorInfo( + typeName, + StatusCode: (int?)httpEx.StatusCode), + + // FileNotFoundException before IOException (it derives from IOException) + FileNotFoundException fnfEx => new ExceptionErrorInfo( + typeName, + HResult: fnfEx.HResult, + Details: fnfEx.FileName is not null ? "file_specified" : null), + + IOException ioEx => new ExceptionErrorInfo( + typeName, + HResult: ioEx.HResult), + + _ => new ExceptionErrorInfo(typeName) + }; + } +} diff --git a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs new file mode 100644 index 000000000000..852d506bd069 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using Microsoft.DotNet.Cli.Telemetry; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Provides common telemetry properties for dotnetup. +/// +internal static class TelemetryCommonProperties +{ + private static readonly Lazy s_deviceId = new(GetDeviceId); + private static readonly Lazy s_isCIEnvironment = new(DetectCIEnvironment); + + /// + /// Gets common attributes for the OpenTelemetry resource. + /// + public static IEnumerable> GetCommonAttributes(string sessionId) + { + return new Dictionary + { + ["session.id"] = sessionId, + ["device.id"] = s_deviceId.Value, + ["os.platform"] = GetOSPlatform(), + ["os.version"] = Environment.OSVersion.VersionString, + ["process.arch"] = RuntimeInformation.ProcessArchitecture.ToString(), + ["ci.detected"] = s_isCIEnvironment.Value, + ["dotnetup.version"] = GetVersion() + }; + } + + /// + /// Hashes a path for privacy-safe telemetry. + /// + public static string HashPath(string? path) + { + if (string.IsNullOrEmpty(path)) + { + return string.Empty; + } + + return Hash(path); + } + + /// + /// Computes a SHA256 hash of the input string. + /// + public static string Hash(string input) + { + var bytes = Encoding.UTF8.GetBytes(input); + var hashBytes = SHA256.HashData(bytes); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + private static string GetOSPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "Windows"; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "Linux"; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "macOS"; + } + return "Unknown"; + } + + private static string GetDeviceId() + { + try + { + // Reuse the SDK's device ID getter for consistency + return DeviceIdGetter.GetDeviceId(); + } + catch + { + // Fallback to a new GUID if device ID retrieval fails + return Guid.NewGuid().ToString(); + } + } + + private static bool DetectCIEnvironment() + { + try + { + // Reuse the SDK's CI detection + var detector = new CIEnvironmentDetectorForTelemetry(); + return detector.IsCIEnvironment(); + } + catch + { + return false; + } + } + + private static string GetVersion() + { + return typeof(TelemetryCommonProperties).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? "0.0.0"; + } +} diff --git a/src/Installer/dotnetup/Telemetry/TelemetryEventData.cs b/src/Installer/dotnetup/Telemetry/TelemetryEventData.cs new file mode 100644 index 000000000000..24df5750f3b5 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/TelemetryEventData.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Strongly-typed event data for install operations. +/// +/// The component being installed (e.g., "sdk", "runtime"). +/// The version being installed. +/// The previous version if this is an update. +/// Whether this was an update operation. +/// The installation root path (will be hashed). +/// Time spent downloading. +/// Time spent extracting. +/// Size of the downloaded archive in bytes. +public record InstallEventData( + string Component, + string Version, + string? PreviousVersion, + bool WasUpdate, + string InstallRoot, + TimeSpan DownloadDuration, + TimeSpan ExtractionDuration, + long ArchiveSizeBytes +); + +/// +/// Strongly-typed event data for update operations. +/// +/// The component being updated. +/// The version updating from. +/// The version updating to. +/// The update channel (e.g., "lts", "sts"). +/// Whether this was an automatic update. +public record UpdateEventData( + string Component, + string FromVersion, + string ToVersion, + string UpdateChannel, + bool WasAutomatic +); + +/// +/// Strongly-typed event data for command completion. +/// +/// The command that was executed. +/// The exit code of the command. +/// The duration of the command. +/// Whether the command succeeded. +public record CommandCompletedEventData( + string Command, + int ExitCode, + TimeSpan Duration, + bool Success +); diff --git a/src/Installer/dotnetup/dotnetup.csproj b/src/Installer/dotnetup/dotnetup.csproj index 0756a3133a32..ea5b048b6025 100644 --- a/src/Installer/dotnetup/dotnetup.csproj +++ b/src/Installer/dotnetup/dotnetup.csproj @@ -26,6 +26,7 @@ + @@ -38,6 +39,10 @@ + + + + From b1db5c091dd463d44c6c389b5ae0bba2ebe17acd Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 29 Jan 2026 16:40:34 -0800 Subject: [PATCH 02/59] progress reporters also report telemetry for larger tasks --- .../IProgressTarget.cs | 48 ++++++++---- .../Internal/DotnetArchiveDownloader.cs | 39 ++++++++-- .../Internal/DotnetArchiveExtractor.cs | 48 +++++++----- .../Internal/InstallationActivitySource.cs | 13 +++- .../dotnetup/NonUpdatingProgressTarget.cs | 67 ++++++++++++----- .../dotnetup/SpectreProgressTarget.cs | 74 ++++++++++++++++--- 6 files changed, 223 insertions(+), 66 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs b/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs index eb8779adc702..dff83c9b8bdd 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs @@ -1,20 +1,25 @@ // 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.Text; - namespace Microsoft.Dotnet.Installation; public interface IProgressTarget { - public IProgressReporter CreateProgressReporter(); + IProgressReporter CreateProgressReporter(); } public interface IProgressReporter : IDisposable { - public IProgressTask AddTask(string description, double maxValue); + IProgressTask AddTask(string description, double maxValue); + + /// + /// Adds a task with telemetry activity tracking. + /// + /// The name for the telemetry activity (e.g., "download", "extract"). + /// The user-visible description. + /// The maximum progress value. + IProgressTask AddTask(string activityName, string description, double maxValue) + => AddTask(description, maxValue); // Default: no telemetry } public interface IProgressTask @@ -23,23 +28,38 @@ public interface IProgressTask double Value { get; set; } double MaxValue { get; set; } + /// + /// Sets a telemetry tag on the underlying activity (if any). + /// + void SetTag(string key, object? value) { } + + /// + /// Records an error on the underlying activity (if any). + /// + void RecordError(Exception ex) { } + /// + /// Marks the task as successfully completed. + /// + void Complete() { } } public class NullProgressTarget : IProgressTarget { public IProgressReporter CreateProgressReporter() => new NullProgressReporter(); - class NullProgressReporter : IProgressReporter + + private sealed class NullProgressReporter : IProgressReporter { - public void Dispose() - { - } + public void Dispose() { } + public IProgressTask AddTask(string description, double maxValue) - { - return new NullProgressTask(description); - } + => new NullProgressTask(description); + + public IProgressTask AddTask(string activityName, string description, double maxValue) + => new NullProgressTask(description); } - class NullProgressTask : IProgressTask + + private sealed class NullProgressTask : IProgressTask { public NullProgressTask(string description) { diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index 25ce3f1bdc6c..a6fcddfcf4bd 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -196,11 +196,17 @@ void DownloadArchive(string downloadUrl, string destinationPath, IProgress - /// The .NET installation details + /// The .NET installation request details + /// The resolved version to download /// The local path to save the downloaded file /// Optional progress reporting - /// True if download and verification were successful, false otherwise - public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion, string destinationPath, IProgress? progress = null) + /// Optional progress task for telemetry tags + public void DownloadArchiveWithVerification( + DotnetInstallRequest installRequest, + ReleaseVersion resolvedVersion, + string destinationPath, + IProgress? progress = null, + IProgressTask? telemetryTask = null) { var targetFile = _releaseManifest.FindReleaseFile(installRequest, resolvedVersion); string? downloadUrl = targetFile?.Address.ToString(); @@ -215,6 +221,9 @@ public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest, throw new ArgumentException($"{nameof(downloadUrl)} cannot be null or empty"); } + // Set download URL domain for telemetry + telemetryTask?.SetTag("download.url_domain", GetDomain(downloadUrl)); + // Check the cache first string? cachedFilePath = _downloadCache.GetCachedFilePath(downloadUrl); if (cachedFilePath != null) @@ -223,12 +232,16 @@ public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest, { // Verify the cached file's hash VerifyFileHash(cachedFilePath, expectedHash); - + // Copy from cache to destination File.Copy(cachedFilePath, destinationPath, overwrite: true); - + // Report 100% progress immediately since we're using cache progress?.Report(new DownloadProgress(100, 100)); + + var cachedFileInfo = new FileInfo(cachedFilePath); + telemetryTask?.SetTag("download.bytes", cachedFileInfo.Length); + telemetryTask?.SetTag("download.from_cache", true); return; } catch @@ -243,6 +256,10 @@ public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest, // Verify the downloaded file VerifyFileHash(destinationPath, expectedHash); + var fileInfo = new FileInfo(destinationPath); + telemetryTask?.SetTag("download.bytes", fileInfo.Length); + telemetryTask?.SetTag("download.from_cache", false); + // Add the verified file to the cache try { @@ -254,6 +271,18 @@ public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest, } } + private static string GetDomain(string url) + { + try + { + return new Uri(url).Host; + } + catch + { + return "unknown"; + } + } + /// /// Computes the SHA512 hash of a file. diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index 85e546cd71b0..36a14670177c 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -19,6 +19,7 @@ internal class DotnetArchiveExtractor : IDisposable private readonly IProgressTarget _progressTarget; private string scratchDownloadDirectory; private string? _archivePath; + private int _extractedFileCount; public DotnetArchiveExtractor(DotnetInstallRequest request, ReleaseVersion resolvedVersion, ReleaseManifest releaseManifest, IProgressTarget progressTarget) { @@ -30,40 +31,49 @@ public DotnetArchiveExtractor(DotnetInstallRequest request, ReleaseVersion resol public void Prepare() { - using var activity = InstallationActivitySource.ActivitySource.StartActivity("DotnetInstaller.Prepare"); - using var archiveDownloader = new DotnetArchiveDownloader(); var archiveName = $"dotnet-{Guid.NewGuid()}"; _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DotnetupUtilities.GetArchiveFileExtensionForPlatform()); - using (var progressReporter = _progressTarget.CreateProgressReporter()) - { - var downloadTask = progressReporter.AddTask($"Downloading .NET SDK {_resolvedVersion}", 100); - var reporter = new DownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_resolvedVersion}"); + using var progressReporter = _progressTarget.CreateProgressReporter(); + var downloadTask = progressReporter.AddTask("download", $"Downloading .NET SDK {_resolvedVersion}", 100); + downloadTask.SetTag("download.component", _request.Component.ToString()); + downloadTask.SetTag("download.version", _resolvedVersion.ToString()); - try - { - archiveDownloader.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter); - } - catch (Exception ex) - { - throw new Exception($"Failed to download .NET archive for version {_resolvedVersion}", ex); - } + var reporter = new DownloadProgressReporter(downloadTask, $"Downloading .NET SDK {_resolvedVersion}"); + try + { + archiveDownloader.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter, downloadTask); downloadTask.Value = 100; + downloadTask.Complete(); + } + catch (Exception ex) + { + downloadTask.RecordError(ex); + throw new Exception($"Failed to download .NET archive for version {_resolvedVersion}", ex); } } + public void Commit() { - using var activity = InstallationActivitySource.ActivitySource.StartActivity("DotnetInstaller.Commit"); + using var progressReporter = _progressTarget.CreateProgressReporter(); + var installTask = progressReporter.AddTask("extract", $"Installing .NET SDK {_resolvedVersion}", maxValue: 100); + installTask.SetTag("extract.component", _request.Component.ToString()); + installTask.SetTag("extract.version", _resolvedVersion.ToString()); - using (var progressReporter = _progressTarget.CreateProgressReporter()) + try { - var installTask = progressReporter.AddTask($"Installing .NET SDK {_resolvedVersion}", maxValue: 100); - // Extract archive directly to target directory with special handling for muxer ExtractArchiveDirectlyToTarget(_archivePath!, _request.InstallRoot.Path!, installTask); installTask.Value = installTask.MaxValue; + installTask.SetTag("extract.file_count", _extractedFileCount); + installTask.Complete(); + } + catch (Exception ex) + { + installTask.RecordError(ex); + throw; } } @@ -283,6 +293,7 @@ private void ExtractTarFileEntry(TarEntry entry, string targetDir, IProgressTask using var outStream = File.Create(destPath); entry.DataStream?.CopyTo(outStream); installTask?.Value += 1; + _extractedFileCount++; } /// @@ -332,6 +343,7 @@ private void ExtractZipEntry(ZipArchiveEntry entry, string targetDir, IProgressT Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); entry.ExtractToFile(destPath, overwrite: true); installTask?.Value += 1; + _extractedFileCount++; } public void Dispose() diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs index d66ba0d51bc2..ed29b2221ed6 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs @@ -6,9 +6,20 @@ namespace Microsoft.Dotnet.Installation.Internal; +/// +/// Provides the ActivitySource for installation telemetry. +/// This source is listened to by dotnetup's DotnetupTelemetry when running via CLI, +/// and can be subscribed to by other consumers via ActivityListener. +/// internal static class InstallationActivitySource { - private static readonly ActivitySource s_activitySource = new("Microsoft.Dotnet.Installer", + /// + /// The name of the ActivitySource. Must match what consumers listen for. + /// + public const string SourceName = "Microsoft.Dotnet.Installation"; + + private static readonly ActivitySource s_activitySource = new( + SourceName, typeof(InstallationActivitySource).Assembly.GetCustomAttribute()?.InformationalVersion ?? "1.0.0"); public static ActivitySource ActivitySource => s_activitySource; diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index 5903cd319f7f..003087d8faab 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -1,10 +1,9 @@ // 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.Reflection.Metadata.Ecma335; -using System.Text; +using System.Diagnostics; +using Microsoft.Dotnet.Installation.Internal; +using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -12,37 +11,47 @@ public class NonUpdatingProgressTarget : IProgressTarget { public IProgressReporter CreateProgressReporter() => new Reporter(); - class Reporter : IProgressReporter + private sealed class Reporter : IProgressReporter { - List _tasks = new(); + private readonly List _tasks = new(); public IProgressTask AddTask(string description, double maxValue) { - var task = new ProgressTaskImpl(description) - { - MaxValue = maxValue - }; + var task = new ProgressTaskImpl(description, activity: null) { MaxValue = maxValue }; + _tasks.Add(task); + AnsiConsole.WriteLine(description + "..."); + return task; + } + + public IProgressTask AddTask(string activityName, string description, double maxValue) + { + var activity = InstallationActivitySource.ActivitySource.StartActivity(activityName, ActivityKind.Internal); + var task = new ProgressTaskImpl(description, activity) { MaxValue = maxValue }; _tasks.Add(task); - Spectre.Console.AnsiConsole.WriteLine(description + "..."); + AnsiConsole.WriteLine(description + "..."); return task; } + public void Dispose() { foreach (var task in _tasks) { task.Complete(); + task.DisposeActivity(); } } } - class ProgressTaskImpl : IProgressTask + private sealed class ProgressTaskImpl : IProgressTask { - bool _completed = false; - double _value; + private readonly Activity? _activity; + private bool _completed; + private double _value; - public ProgressTaskImpl(string description) + public ProgressTaskImpl(string description, Activity? activity) { Description = description; + _activity = activity; } public double Value @@ -57,16 +66,40 @@ public double Value } } } + public string Description { get; set; } public double MaxValue { get; set; } + public void SetTag(string key, object? value) => _activity?.SetTag(key, value); + + public void RecordError(Exception ex) + { + if (_activity == null) return; + _activity.SetStatus(ActivityStatusCode.Error, ex.Message); + _activity.SetTag("error.type", ex.GetType().Name); + _activity.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection + { + { "exception.type", ex.GetType().FullName }, + { "exception.message", ex.Message } + })); + } + public void Complete() { + if (_completed) return; + _completed = true; + _activity?.SetStatus(ActivityStatusCode.Ok); + AnsiConsole.MarkupLine($"[green]Completed:[/] {Description}"); + } + + public void DisposeActivity() + { + // Don't print "Completed" again if already completed if (!_completed) { - Spectre.Console.AnsiConsole.MarkupLine($"[green]Completed:[/] {Description}"); - _completed = true; + _activity?.SetStatus(ActivityStatusCode.Unset); } + _activity?.Dispose(); } } } diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index b1998bf09942..bc975d139ed3 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -1,10 +1,8 @@ // 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.Reflection.Metadata.Ecma335; -using System.Text; +using System.Diagnostics; +using Microsoft.Dotnet.Installation.Internal; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -13,14 +11,15 @@ public class SpectreProgressTarget : IProgressTarget { public IProgressReporter CreateProgressReporter() => new Reporter(); - class Reporter : IProgressReporter + private sealed class Reporter : IProgressReporter { - TaskCompletionSource _overallTask = new TaskCompletionSource(); - ProgressContext _progressContext; + private readonly TaskCompletionSource _overallTask = new(); + private readonly ProgressContext _progressContext; + private readonly List _tasks = new(); public Reporter() { - TaskCompletionSource tcs = new TaskCompletionSource(); + TaskCompletionSource tcs = new(); var progressTask = AnsiConsole.Progress().StartAsync(async ctx => { tcs.SetResult(ctx); @@ -32,21 +31,39 @@ public Reporter() public IProgressTask AddTask(string description, double maxValue) { - return new ProgressTaskImpl(_progressContext.AddTask(description, maxValue: maxValue)); + var task = new ProgressTaskImpl(_progressContext.AddTask(description, maxValue: maxValue), activity: null); + _tasks.Add(task); + return task; + } + + public IProgressTask AddTask(string activityName, string description, double maxValue) + { + var activity = InstallationActivitySource.ActivitySource.StartActivity(activityName, ActivityKind.Internal); + var task = new ProgressTaskImpl(_progressContext.AddTask(description, maxValue: maxValue), activity); + _tasks.Add(task); + return task; } public void Dispose() { + foreach (var task in _tasks) + { + task.DisposeActivity(); + } _overallTask.SetResult(); } } - class ProgressTaskImpl : IProgressTask + private sealed class ProgressTaskImpl : IProgressTask { private readonly Spectre.Console.ProgressTask _task; - public ProgressTaskImpl(Spectre.Console.ProgressTask task) + private readonly Activity? _activity; + private bool _completed; + + public ProgressTaskImpl(Spectre.Console.ProgressTask task, Activity? activity) { _task = task; + _activity = activity; } public double Value @@ -54,15 +71,50 @@ public double Value get => _task.Value; set => _task.Value = value; } + public string Description { get => _task.Description; set => _task.Description = value; } + public double MaxValue { get => _task.MaxValue; set => _task.MaxValue = value; } + + public void SetTag(string key, object? value) => _activity?.SetTag(key, value); + + public void RecordError(Exception ex) + { + if (_activity == null) return; + _activity.SetStatus(ActivityStatusCode.Error, ex.Message); + _activity.SetTag("error.type", ex.GetType().Name); + _activity.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection + { + { "exception.type", ex.GetType().FullName }, + { "exception.message", ex.Message } + })); + } + + public void Complete() + { + if (_completed) return; + _completed = true; + _activity?.SetStatus(ActivityStatusCode.Ok); + } + + public void DisposeActivity() + { + // Ensure Spectre task shows as complete (visually) + _task.Value = _task.MaxValue; + + if (!_completed) + { + _activity?.SetStatus(ActivityStatusCode.Unset); + } + _activity?.Dispose(); + } } } From 6a1e7c44478bae38892df2889d70f8cc29ad99a4 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 29 Jan 2026 16:57:20 -0800 Subject: [PATCH 03/59] --info has telemetry and use custom App I for now --- .../dotnetup/Commands/Info/InfoCommand.cs | 27 ++++++++++++++----- .../Commands/Info/InfoCommandParser.cs | 2 +- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 2 +- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Installer/dotnetup/Commands/Info/InfoCommand.cs b/src/Installer/dotnetup/Commands/Info/InfoCommand.cs index 3ffa4fab3a9e..08aaf740405f 100644 --- a/src/Installer/dotnetup/Commands/Info/InfoCommand.cs +++ b/src/Installer/dotnetup/Commands/Info/InfoCommand.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.CommandLine; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; @@ -9,28 +10,40 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Info; -internal static class InfoCommand +internal class InfoCommand : CommandBase { - public static int Execute(bool jsonOutput, bool noList = false, TextWriter? output = null) + private readonly bool _jsonOutput; + private readonly bool _noList; + private readonly TextWriter _output; + + public InfoCommand(ParseResult parseResult, bool jsonOutput, bool noList = false, TextWriter? output = null) + : base(parseResult) { - output ??= Console.Out; + _jsonOutput = jsonOutput; + _noList = noList; + _output = output ?? Console.Out; + } + protected override string GetCommandName() => "info"; + + protected override int ExecuteCore() + { var info = GetDotnetupInfo(); List? installations = null; - if (!noList) + if (!_noList) { // --info verifies by default installations = InstallationLister.GetInstallations(verify: true); } - if (jsonOutput) + if (_jsonOutput) { - PrintJsonInfo(output, info, installations); + PrintJsonInfo(_output, info, installations); } else { - PrintHumanReadableInfo(output, info, installations); + PrintHumanReadableInfo(_output, info, installations); } return 0; diff --git a/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs b/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs index 9f3529a4c938..11d3299d9983 100644 --- a/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs @@ -33,7 +33,7 @@ private static Command ConstructCommand() { var jsonOutput = parseResult.GetValue(JsonOption); var noList = parseResult.GetValue(NoListOption); - return Info.InfoCommand.Execute(jsonOutput, noList); + return new InfoCommand(parseResult, jsonOutput, noList).Execute(); }); return command; diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index f4c111f3b117..0d38138eea34 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -35,7 +35,7 @@ public sealed class DotnetupTelemetry : IDisposable /// /// Connection string for Application Insights (same as dotnet CLI). /// - private const string ConnectionString = "InstrumentationKey=74cc1c9e-3e6e-4d05-b3fc-dde9101d0254"; + private const string ConnectionString = "InstrumentationKey=04172778-3bc9-4db6-b50f-cafe87756a47;IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=fbd94297-7083-42b8-aaa5-1886192b4272"; /// /// Environment variable to opt out of telemetry. From b62dd81b32a784a88832f5f1c3d039f9682ec8b2 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 11:52:52 -0800 Subject: [PATCH 04/59] failures should be properly recorded --- src/Installer/dotnetup/CommandBase.cs | 60 +++++++++---------- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 10 +++- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index 085b349e70fe..4d83cf77c194 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -40,46 +40,16 @@ public int Execute() stopwatch.Stop(); _commandActivity?.SetTag("exit.code", exitCode); + _commandActivity?.SetTag("duration_ms", stopwatch.Elapsed.TotalMilliseconds); _commandActivity?.SetStatus(exitCode == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); - // Post completion event for metrics - DotnetupTelemetry.Instance.PostEvent("command/completed", new Dictionary - { - ["command"] = commandName, - ["exit_code"] = exitCode.ToString(), - ["success"] = (exitCode == 0).ToString() - }, new Dictionary - { - ["duration_ms"] = stopwatch.Elapsed.TotalMilliseconds - }); - return exitCode; } catch (Exception ex) { stopwatch.Stop(); + _commandActivity?.SetTag("duration_ms", stopwatch.Elapsed.TotalMilliseconds); DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); - - // Post failure event - var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - var props = new Dictionary - { - ["command"] = commandName, - ["error_type"] = errorInfo.ErrorType - }; - if (errorInfo.StatusCode.HasValue) - { - props["http_status"] = errorInfo.StatusCode.Value.ToString(); - } - if (errorInfo.HResult.HasValue) - { - props["hresult"] = errorInfo.HResult.Value.ToString(); - } - DotnetupTelemetry.Instance.PostEvent("command/failed", props, new Dictionary - { - ["duration_ms"] = stopwatch.Elapsed.TotalMilliseconds - }); - throw; } finally @@ -115,4 +85,30 @@ protected void SetCommandTag(string key, object? value) { _commandActivity?.SetTag(key, value); } + + /// + /// Records a failure reason without throwing an exception. + /// Use this when returning a non-zero exit code to capture the error context. + /// + /// A short error reason code (e.g., "path_mismatch", "download_failed"). + /// Optional detailed error message. + protected void RecordFailure(string reason, string? message = null) + { + _commandActivity?.SetTag("error.type", reason); + if (message != null) + { + _commandActivity?.SetTag("error.message", message); + } + } + + /// + /// Records the requested version/channel with PII sanitization. + /// Only known safe patterns are passed through; unknown patterns are replaced with "invalid". + /// + /// The raw version or channel string from user input. + protected void RecordRequestedVersion(string? versionOrChannel) + { + var sanitized = VersionSanitizer.Sanitize(versionOrChannel); + _commandActivity?.SetTag("sdk.requested_version", sanitized); + } } diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index 0d38138eea34..6313add532d6 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -117,7 +117,15 @@ private DotnetupTelemetry() } var activity = CommandSource.StartActivity($"command/{commandName}", ActivityKind.Internal); - activity?.SetTag("command.name", commandName); + if (activity != null) + { + activity.SetTag("command.name", commandName); + // Add common properties to each span for App Insights customDimensions + foreach (var attr in TelemetryCommonProperties.GetCommonAttributes(_sessionId)) + { + activity.SetTag(attr.Key, attr.Value?.ToString()); + } + } activity?.SetTag("caller", "dotnetup"); activity?.SetTag("session.id", _sessionId); return activity; From 91e7ad1ab2505c6e0de02145cae083a692f4fc42 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 11:53:05 -0800 Subject: [PATCH 05/59] catch more detail than just 'exception' for failure --- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index c44ee948478c..c6ecb4805442 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -2,13 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; +using Microsoft.Dotnet.Installation; namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// /// Error info extracted from an exception for telemetry. /// -/// The exception type name. +/// The error type/code for telemetry. /// HTTP status code if applicable. /// Win32 HResult if applicable. /// Additional context like file path. @@ -36,25 +37,57 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) return GetErrorInfo(aggEx.InnerExceptions[0]); } - var typeName = ex.GetType().Name; + // If it's a plain Exception wrapper, use the inner exception for better error type + if (ex.GetType() == typeof(Exception) && ex.InnerException is not null) + { + return GetErrorInfo(ex.InnerException); + } return ex switch { + // DotnetInstallException has specific error codes + DotnetInstallException installEx => new ExceptionErrorInfo( + installEx.ErrorCode.ToString(), + Details: installEx.Version), + HttpRequestException httpEx => new ExceptionErrorInfo( - typeName, + httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", StatusCode: (int?)httpEx.StatusCode), // FileNotFoundException before IOException (it derives from IOException) FileNotFoundException fnfEx => new ExceptionErrorInfo( - typeName, + "FileNotFound", HResult: fnfEx.HResult, Details: fnfEx.FileName is not null ? "file_specified" : null), - IOException ioEx => new ExceptionErrorInfo( - typeName, - HResult: ioEx.HResult), + UnauthorizedAccessException => new ExceptionErrorInfo("PermissionDenied"), + + DirectoryNotFoundException => new ExceptionErrorInfo("DirectoryNotFound"), + + IOException ioEx => MapIOException(ioEx), + + OperationCanceledException => new ExceptionErrorInfo("Cancelled"), + + ArgumentException argEx => new ExceptionErrorInfo( + "InvalidArgument", + Details: argEx.ParamName), - _ => new ExceptionErrorInfo(typeName) + _ => new ExceptionErrorInfo(ex.GetType().Name) + }; + } + + private static ExceptionErrorInfo MapIOException(IOException ioEx) + { + // Check for common HResult values + const int ERROR_DISK_FULL = unchecked((int)0x80070070); + const int ERROR_HANDLE_DISK_FULL = unchecked((int)0x80070027); + const int ERROR_ACCESS_DENIED = unchecked((int)0x80070005); + + return ioEx.HResult switch + { + ERROR_DISK_FULL or ERROR_HANDLE_DISK_FULL => new ExceptionErrorInfo("DiskFull", HResult: ioEx.HResult), + ERROR_ACCESS_DENIED => new ExceptionErrorInfo("PermissionDenied", HResult: ioEx.HResult), + _ => new ExceptionErrorInfo("IOException", HResult: ioEx.HResult) }; } } From 433525058912dffafe068d3c50ac816cfb70ea49 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 11:53:27 -0800 Subject: [PATCH 06/59] share accepted channel values --- .../DotnetInstallException.cs | 92 +++++++++++ .../Internal/ChannelVersionResolver.cs | 35 ++++- .../Commands/Sdk/Install/SdkInstallCommand.cs | 9 +- .../dotnetup/Telemetry/VersionSanitizer.cs | 143 ++++++++++++++++++ 4 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs create mode 100644 src/Installer/dotnetup/Telemetry/VersionSanitizer.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs new file mode 100644 index 000000000000..54188bb35a26 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Dotnet.Installation; + +/// +/// Error codes for .NET installation failures. +/// +public enum DotnetInstallErrorCode +{ + /// Unknown error. + Unknown, + + /// The requested version was not found in the releases index. + VersionNotFound, + + /// The requested release was not found. + ReleaseNotFound, + + /// No matching file was found for the platform/architecture. + NoMatchingFile, + + /// Failed to download the archive. + DownloadFailed, + + /// Archive hash verification failed. + HashMismatch, + + /// Failed to extract the archive. + ExtractionFailed, + + /// The channel or version format is invalid. + InvalidChannel, + + /// Network connectivity issue. + NetworkError, + + /// Insufficient permissions. + PermissionDenied, + + /// Disk space issue. + DiskFull, +} + +/// +/// Exception thrown when a .NET installation operation fails. +/// +public class DotnetInstallException : Exception +{ + /// + /// Gets the error code for this exception. + /// + public DotnetInstallErrorCode ErrorCode { get; } + + /// + /// Gets the version that was being installed, if applicable. + /// + public string? Version { get; } + + /// + /// Gets the component being installed (SDK, Runtime, etc.). + /// + public string? Component { get; } + + public DotnetInstallException(DotnetInstallErrorCode errorCode, string message) + : base(message) + { + ErrorCode = errorCode; + } + + public DotnetInstallException(DotnetInstallErrorCode errorCode, string message, Exception innerException) + : base(message, innerException) + { + ErrorCode = errorCode; + } + + public DotnetInstallException(DotnetInstallErrorCode errorCode, string message, string? version = null, string? component = null) + : base(message) + { + ErrorCode = errorCode; + Version = version; + Component = component; + } + + public DotnetInstallException(DotnetInstallErrorCode errorCode, string message, Exception innerException, string? version = null, string? component = null) + : base(message, innerException) + { + ErrorCode = errorCode; + Version = version; + Component = component; + } +} diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs index d3825ed16ac3..76981ce3632f 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs @@ -10,6 +10,31 @@ namespace Microsoft.Dotnet.Installation.Internal; internal class ChannelVersionResolver { + /// + /// Channel keyword for the latest stable release. + /// + public const string LatestChannel = "latest"; + + /// + /// Channel keyword for the latest preview release. + /// + public const string PreviewChannel = "preview"; + + /// + /// Channel keyword for the latest Long Term Support (LTS) release. + /// + public const string LtsChannel = "lts"; + + /// + /// Channel keyword for the latest Standard Term Support (STS) release. + /// + public const string StsChannel = "sts"; + + /// + /// Known channel keywords that are always valid. + /// + public static readonly IReadOnlyList KnownChannelKeywords = [LatestChannel, PreviewChannel, LtsChannel, StsChannel]; + private ReleaseManifest _releaseManifest = new(); public ChannelVersionResolver() @@ -25,7 +50,7 @@ public ChannelVersionResolver(ReleaseManifest releaseManifest) public IEnumerable GetSupportedChannels() { var productIndex = _releaseManifest.GetReleasesIndex(); - return ["latest", "preview", "lts", "sts", + return [..KnownChannelKeywords, ..productIndex .Where(p => p.IsSupported) .OrderByDescending(p => p.LatestReleaseVersion) @@ -92,18 +117,18 @@ static IEnumerable GetChannelsForProduct(Product product) /// Latest fully specified version string, or null if not found public ReleaseVersion? GetLatestVersionForChannel(UpdateChannel channel, InstallComponent component) { - if (string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) || string.Equals(channel.Name, "sts", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(channel.Name, LtsChannel, StringComparison.OrdinalIgnoreCase) || string.Equals(channel.Name, StsChannel, StringComparison.OrdinalIgnoreCase)) { - var releaseType = string.Equals(channel.Name, "lts", StringComparison.OrdinalIgnoreCase) ? ReleaseType.LTS : ReleaseType.STS; + var releaseType = string.Equals(channel.Name, LtsChannel, StringComparison.OrdinalIgnoreCase) ? ReleaseType.LTS : ReleaseType.STS; var productIndex = _releaseManifest.GetReleasesIndex(); return GetLatestVersionByReleaseType(productIndex, releaseType, component); } - else if (string.Equals(channel.Name, "preview", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(channel.Name, PreviewChannel, StringComparison.OrdinalIgnoreCase)) { var productIndex = _releaseManifest.GetReleasesIndex(); return GetLatestPreviewVersion(productIndex, component); } - else if (string.Equals(channel.Name, "latest", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(channel.Name, LatestChannel, StringComparison.OrdinalIgnoreCase)) { var productIndex = _releaseManifest.GetReleasesIndex(); return GetLatestActiveVersion(productIndex, component); diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs index 7ad9e6865cf1..4c60462b3c70 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -31,6 +31,9 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) protected override int ExecuteCore() { + // Record the requested version with PII sanitization + RecordRequestedVersion(_versionOrChannel); + var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); var currentDotnetInstallRoot = _dotnetInstaller.GetConfiguredInstallType(); @@ -47,6 +50,7 @@ protected override int ExecuteCore() { // TODO: Add parameter to override error Console.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); + RecordFailure("path_mismatch", $"global.json path ({installPathFromGlobalJson}) != provided path ({_installPath})"); return 1; } @@ -121,11 +125,11 @@ protected override int ExecuteCore() resolvedChannel = SpectreAnsiConsole.Prompt( new TextPrompt("Which channel of the .NET SDK do you want to install?") - .DefaultValue("latest")); + .DefaultValue(ChannelVersionResolver.LatestChannel)); } else { - resolvedChannel = "latest"; // Default to latest if no channel is specified + resolvedChannel = ChannelVersionResolver.LatestChannel; // Default to latest if no channel is specified } } @@ -260,6 +264,7 @@ protected override int ExecuteCore() if (mainInstall == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedVersion}[/]"); + RecordFailure("install_failed", $"Failed to install SDK {resolvedVersion}"); return 1; } SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {mainInstall.Version}, available via {mainInstall.InstallRoot}[/]"); diff --git a/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs b/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs new file mode 100644 index 000000000000..4a720a2512d9 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Sanitizes version/channel strings for telemetry to prevent PII leakage. +/// Only known safe patterns are passed through; unknown patterns are replaced with "invalid". +/// +public static partial class VersionSanitizer +{ + /// + /// Known safe channel keywords (sourced from ChannelVersionResolver). + /// + private static readonly HashSet SafeKeywords = new(ChannelVersionResolver.KnownChannelKeywords, StringComparer.OrdinalIgnoreCase); + + /// + /// Known safe prerelease tokens that can appear after a hyphen in version strings. + /// These are standard .NET prerelease identifiers. + /// + public static readonly IReadOnlyList KnownPrereleaseTokens = ["preview", "rc", "rtm", "ga", "alpha", "beta", "dev", "ci", "servicing"]; + + /// + /// Regex pattern for valid version formats (without prerelease suffix): + /// - Major only: 8, 9, 10 + /// - Major.Minor: 8.0, 9.0, 10.0 + /// - Feature band: 8.0.1xx, 9.0.3xx, 10.0.1xx + /// - Specific version: 8.0.100, 9.0.304 + /// + [GeneratedRegex(@"^(\d{1,2})(\.\d{1,2})?(\.\d{1,3}(xx)?)?$")] + private static partial Regex BaseVersionPatternRegex(); + + /// + /// Regex pattern for prerelease suffix: hyphen followed by numbers and dots only. + /// Example: -1.24234.5 (the token part like "preview" is validated separately) + /// + [GeneratedRegex(@"^(\.\d+)+$")] + private static partial Regex PrereleaseSuffixRegex(); + + /// + /// Sanitizes a version or channel string for safe telemetry collection. + /// + /// The raw version or channel string from user input. + /// The sanitized string, or "invalid" if the pattern is not recognized. + public static string Sanitize(string? versionOrChannel) + { + if (string.IsNullOrWhiteSpace(versionOrChannel)) + { + return "unspecified"; + } + + var trimmed = versionOrChannel.Trim(); + + // Check for known safe keywords + if (SafeKeywords.Contains(trimmed)) + { + return trimmed.ToLowerInvariant(); + } + + // Check for valid version pattern + if (IsValidVersionPattern(trimmed)) + { + return trimmed; + } + + // Unknown pattern - could contain PII, obfuscate it + return "invalid"; + } + + /// + /// Checks if a version string matches a valid pattern. + /// + private static bool IsValidVersionPattern(string version) + { + // Check if there's a prerelease suffix (contains hyphen) + var hyphenIndex = version.IndexOf('-'); + if (hyphenIndex < 0) + { + // No prerelease suffix, just validate the base version + return BaseVersionPatternRegex().IsMatch(version); + } + + // Split into base version and prerelease parts + var baseVersion = version[..hyphenIndex]; + var prereleasePart = version[(hyphenIndex + 1)..]; + + // Validate base version + if (!BaseVersionPatternRegex().IsMatch(baseVersion)) + { + return false; + } + + // Validate prerelease part: must start with a known token + // Format: token[.number]* (e.g., "preview", "preview.1", "preview.1.24234.5", "rc.1") + var dotIndex = prereleasePart.IndexOf('.'); + string token; + string? suffix; + + if (dotIndex < 0) + { + token = prereleasePart; + suffix = null; + } + else + { + token = prereleasePart[..dotIndex]; + suffix = prereleasePart[dotIndex..]; // Includes the leading dot + } + + // Token must be a known prerelease identifier + if (!KnownPrereleaseTokens.Contains(token, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + // If there's a suffix, it must be numbers separated by dots + if (suffix != null && !PrereleaseSuffixRegex().IsMatch(suffix)) + { + return false; + } + + return true; + } + + /// + /// Checks if a version/channel string matches a known safe pattern. + /// + /// The version or channel string to check. + /// True if the pattern is recognized as safe. + public static bool IsSafePattern(string? versionOrChannel) + { + if (string.IsNullOrWhiteSpace(versionOrChannel)) + { + return true; // Empty is safe (will be reported as "unspecified") + } + + var trimmed = versionOrChannel.Trim(); + return SafeKeywords.Contains(trimmed) || IsValidVersionPattern(trimmed); + } +} From 19942d96628ac11b60f6dc9fc467b387a975a8e7 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 11:53:40 -0800 Subject: [PATCH 07/59] specific error for invalid versions --- .../Internal/ReleaseManifest.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 2c29a86f171c..033e03ab10f1 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -29,13 +29,32 @@ public ReleaseManifest() try { var productCollection = GetReleasesIndex(); - var product = FindProduct(productCollection, resolvedVersion) ?? throw new InvalidOperationException($"No product found for version {resolvedVersion}"); - var release = FindRelease(product, resolvedVersion, installRequest.Component) ?? throw new InvalidOperationException($"No release found for version {resolvedVersion}"); + var product = FindProduct(productCollection, resolvedVersion) + ?? throw new DotnetInstallException( + DotnetInstallErrorCode.VersionNotFound, + $"No product found for version {resolvedVersion}", + version: resolvedVersion.ToString(), + component: installRequest.Component.ToString()); + var release = FindRelease(product, resolvedVersion, installRequest.Component) + ?? throw new DotnetInstallException( + DotnetInstallErrorCode.ReleaseNotFound, + $"No release found for version {resolvedVersion}", + version: resolvedVersion.ToString(), + component: installRequest.Component.ToString()); return FindMatchingFile(release, installRequest, resolvedVersion); } + catch (DotnetInstallException) + { + throw; + } catch (Exception ex) { - throw new InvalidOperationException($"Failed to find an available release for install {installRequest} : ${ex.Message}", ex); + throw new DotnetInstallException( + DotnetInstallErrorCode.Unknown, + $"Failed to find an available release for install {installRequest}: {ex.Message}", + ex, + version: resolvedVersion.ToString(), + component: installRequest.Component.ToString()); } } From dc384ecd4c0eb5001b60233149111b79f0955c8b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 12:20:36 -0800 Subject: [PATCH 08/59] add version sanitization tests --- .../dotnetup/Telemetry/VersionSanitizer.cs | 8 +- test/dotnetup.Tests/InfoCommandTests.cs | 28 +- test/dotnetup.Tests/VersionSanitizerTests.cs | 241 ++++++++++++++++++ 3 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 test/dotnetup.Tests/VersionSanitizerTests.cs diff --git a/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs b/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs index 4a720a2512d9..0025a7bb4165 100644 --- a/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs +++ b/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs @@ -27,10 +27,14 @@ public static partial class VersionSanitizer /// Regex pattern for valid version formats (without prerelease suffix): /// - Major only: 8, 9, 10 /// - Major.Minor: 8.0, 9.0, 10.0 - /// - Feature band: 8.0.1xx, 9.0.3xx, 10.0.1xx + /// - Feature band wildcard: 8.0.1xx, 9.0.3xx, 10.0.1xx (single digit + xx) + /// - Single digit wildcard: 10.0.10x, 10.0.20x (two digits + single x) /// - Specific version: 8.0.100, 9.0.304 + /// Note: Patch versions are max 3 digits (100-999), so wildcards are constrained: + /// - Nxx pattern: single digit (1-9) + xx for feature bands (100-999) + /// - NNx pattern: two digits (10-99) + single x for narrower ranges (100-999) /// - [GeneratedRegex(@"^(\d{1,2})(\.\d{1,2})?(\.\d{1,3}(xx)?)?$")] + [GeneratedRegex(@"^(\d{1,2})(\.\d{1,2})?(\.\d{1,3}|\.\d{1}xx|\.\d{2}x)?$")] private static partial Regex BaseVersionPatternRegex(); /// diff --git a/test/dotnetup.Tests/InfoCommandTests.cs b/test/dotnetup.Tests/InfoCommandTests.cs index f450669ed639..52902ce24a7f 100644 --- a/test/dotnetup.Tests/InfoCommandTests.cs +++ b/test/dotnetup.Tests/InfoCommandTests.cs @@ -9,6 +9,22 @@ namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public class InfoCommandTests { + private static InfoCommand CreateInfoCommand(bool jsonOutput, bool noList, TextWriter output) + { + var args = new List { "--info" }; + if (jsonOutput) args.Add("--json"); + if (noList) args.Add("--no-list"); + + var parseResult = Parser.Parse(args.ToArray()); + return new InfoCommand(parseResult, jsonOutput, noList, output); + } + + private static int ExecuteInfoCommand(bool jsonOutput, bool noList, TextWriter output) + { + var command = CreateInfoCommand(jsonOutput, noList, output); + return command.Execute(); + } + [Fact] public void Parser_ShouldParseInfoCommand() { @@ -78,7 +94,7 @@ public void InfoCommand_ShouldReturnZeroExitCode(bool jsonOutput) using var sw = new StringWriter(); // Act - use noList: true to avoid manifest access in unit tests - var exitCode = InfoCommand.Execute(jsonOutput: jsonOutput, noList: true, output: sw); + var exitCode = ExecuteInfoCommand(jsonOutput: jsonOutput, noList: true, output: sw); // Assert exitCode.Should().Be(0); @@ -91,7 +107,7 @@ public void InfoCommand_HumanReadable_ShouldOutputExpectedFormat() using var sw = new StringWriter(); // Act - use noList: true to avoid manifest access in unit tests - InfoCommand.Execute(jsonOutput: false, noList: true, output: sw); + ExecuteInfoCommand(jsonOutput: false, noList: true, output: sw); var output = sw.ToString(); // Assert @@ -109,7 +125,7 @@ public void InfoCommand_HumanReadable_WithList_ShouldIncludeListOutput() using var sw = new StringWriter(); // Act - include list (may be empty but should show the header) - InfoCommand.Execute(jsonOutput: false, noList: false, output: sw); + ExecuteInfoCommand(jsonOutput: false, noList: false, output: sw); var output = sw.ToString(); // Assert @@ -125,7 +141,7 @@ public void InfoCommand_Json_ShouldOutputValidJson() using var sw = new StringWriter(); // Act - use noList: true to avoid manifest access in unit tests - InfoCommand.Execute(jsonOutput: true, noList: true, output: sw); + ExecuteInfoCommand(jsonOutput: true, noList: true, output: sw); var output = sw.ToString(); // Assert - should be valid JSON @@ -140,7 +156,7 @@ public void InfoCommand_Json_ShouldContainExpectedProperties() using var sw = new StringWriter(); // Act - use noList: true to avoid manifest access in unit tests - InfoCommand.Execute(jsonOutput: true, noList: true, output: sw); + ExecuteInfoCommand(jsonOutput: true, noList: true, output: sw); var output = sw.ToString(); // Assert @@ -160,7 +176,7 @@ public void InfoCommand_Json_WithList_ShouldContainInstallationsProperty() using var sw = new StringWriter(); // Act - include list - InfoCommand.Execute(jsonOutput: true, noList: false, output: sw); + ExecuteInfoCommand(jsonOutput: true, noList: false, output: sw); var output = sw.ToString(); // Assert diff --git a/test/dotnetup.Tests/VersionSanitizerTests.cs b/test/dotnetup.Tests/VersionSanitizerTests.cs new file mode 100644 index 000000000000..268503a459ef --- /dev/null +++ b/test/dotnetup.Tests/VersionSanitizerTests.cs @@ -0,0 +1,241 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +public class VersionSanitizerTests +{ + #region Channel Keywords + + [Theory] + [InlineData("latest", "latest")] + [InlineData("LATEST", "latest")] + [InlineData("lts", "lts")] + [InlineData("LTS", "lts")] + [InlineData("sts", "sts")] + [InlineData("STS", "sts")] + [InlineData("preview", "preview")] + [InlineData("PREVIEW", "preview")] + public void Sanitize_ChannelKeywords_ReturnsLowercase(string input, string expected) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(expected); + } + + #endregion + + #region Empty/Null Input + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Sanitize_EmptyOrNull_ReturnsUnspecified(string? input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be("unspecified"); + } + + #endregion + + #region Valid Version Patterns - Major Only + + [Theory] + [InlineData("8")] + [InlineData("9")] + [InlineData("10")] + public void Sanitize_MajorVersionOnly_ReturnsAsIs(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(input); + } + + #endregion + + #region Valid Version Patterns - Major.Minor + + [Theory] + [InlineData("8.0")] + [InlineData("9.0")] + [InlineData("10.0")] + public void Sanitize_MajorMinorVersion_ReturnsAsIs(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(input); + } + + #endregion + + #region Valid Version Patterns - Feature Band Wildcards + + [Theory] + [InlineData("8.0.1xx")] + [InlineData("9.0.3xx")] + [InlineData("10.0.1xx")] + [InlineData("10.0.2xx")] + public void Sanitize_FeatureBandWildcard_ReturnsAsIs(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(input); + } + + [Theory] + [InlineData("10.0.10x")] + [InlineData("10.0.20x")] + [InlineData("8.0.40x")] + [InlineData("9.0.30x")] + public void Sanitize_SingleDigitWildcard_ReturnsAsIs(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(input); + } + + #endregion + + #region Valid Version Patterns - Specific Versions + + [Theory] + [InlineData("8.0.100")] + [InlineData("9.0.304")] + [InlineData("10.0.100")] + [InlineData("10.0.102")] + public void Sanitize_SpecificVersion_ReturnsAsIs(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(input); + } + + #endregion + + #region Valid Version Patterns - With Prerelease Tokens + + [Theory] + [InlineData("10.0.100-preview.1")] + [InlineData("10.0.100-preview.1.24234.5")] + [InlineData("10.0.0-preview.1.25080.5")] + [InlineData("9.0.0-rc.1.24431.7")] + [InlineData("10.0.100-rc.1")] + [InlineData("10.0.100-rc.2.25502.107")] + public void Sanitize_PreviewAndRcVersions_ReturnsAsIs(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(input); + } + + [Theory] + [InlineData("8.0.100-alpha")] + [InlineData("8.0.100-alpha.1")] + [InlineData("8.0.100-beta")] + [InlineData("8.0.100-beta.2")] + [InlineData("8.0.100-rtm")] + [InlineData("8.0.100-ga")] + [InlineData("8.0.100-dev.1")] + [InlineData("8.0.100-ci.12345")] + [InlineData("8.0.100-servicing.1")] + public void Sanitize_OtherKnownPrereleaseTokens_ReturnsAsIs(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(input); + } + + #endregion + + #region Invalid Patterns - PII Protection + + [Theory] + [InlineData("10.0.0-mycreditcardisblank")] + [InlineData("10.0.100-mysecretpassword")] + [InlineData("10.0.100-user@email.com")] + [InlineData("10.0.100-johndoe")] + [InlineData("10.0.100-unknown")] + public void Sanitize_UnknownPrereleaseToken_ReturnsInvalid(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be("invalid"); + } + + [Theory] + [InlineData("not-a-version")] + [InlineData("hello world")] + [InlineData("/path/to/file")] + [InlineData("C:\\Users\\secret")] + [InlineData("some random text with pii")] + public void Sanitize_ArbitraryText_ReturnsInvalid(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be("invalid"); + } + + #endregion + + #region IsSafePattern Tests + + [Theory] + [InlineData("latest", true)] + [InlineData("10.0.100", true)] + [InlineData("10.0.20x", true)] + [InlineData("10.0.1xx", true)] + [InlineData("10.0.100-preview.1", true)] + [InlineData(null, true)] + [InlineData("", true)] + public void IsSafePattern_SafeInputs_ReturnsTrue(string? input, bool expected) + { + var result = VersionSanitizer.IsSafePattern(input); + result.Should().Be(expected); + } + + [Theory] + [InlineData("10.0.0-mypii")] + [InlineData("random-text")] + [InlineData("user@example.com")] + public void IsSafePattern_UnsafeInputs_ReturnsFalse(string input) + { + var result = VersionSanitizer.IsSafePattern(input); + result.Should().BeFalse(); + } + + #endregion + + #region Edge Cases + + [Theory] + [InlineData(" 10.0.100 ", "10.0.100")] // Whitespace trimmed + [InlineData(" latest ", "latest")] // Keyword with whitespace + public void Sanitize_WhitespacePadding_TrimmedCorrectly(string input, string expected) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be(expected); + } + + [Fact] + public void KnownPrereleaseTokens_ContainsExpectedTokens() + { + VersionSanitizer.KnownPrereleaseTokens.Should().Contain("preview"); + VersionSanitizer.KnownPrereleaseTokens.Should().Contain("rc"); + VersionSanitizer.KnownPrereleaseTokens.Should().Contain("alpha"); + VersionSanitizer.KnownPrereleaseTokens.Should().Contain("beta"); + } + + #endregion + + #region Invalid Wildcard Patterns + + [Theory] + [InlineData("10.0.4xxx")] // Too many x's + [InlineData("10.0.10xx")] // Two digits + xx would exceed 3-digit patch limit + [InlineData("10.0.100x")] // Three digits + x would exceed 3-digit patch limit + [InlineData("10.0.1xxx")] // Too many x's + [InlineData("10.0.xxxx")] // No digits, all x's + [InlineData("10.0.xxx")] // No digits, all x's + [InlineData("10.0.xx")] // No digits, just xx + [InlineData("10.0.x")] // No digits, just x + public void Sanitize_InvalidWildcardPatterns_ReturnsInvalid(string input) + { + var result = VersionSanitizer.Sanitize(input); + result.Should().Be("invalid"); + } + + #endregion +} From b95c06f29dbfd596978e526e333e52c0f0e109ec Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 14:41:05 -0800 Subject: [PATCH 09/59] use slnf so tests are found by test explorer in code --- src/Installer/installer.code-workspace | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index 9769a3a8999a..baba8b6e6742 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -13,7 +13,7 @@ } ], "settings": { - "dotnet.defaultSolution": "dotnetup/dotnetup.csproj", + "dotnet.defaultSolution": "../../dotnetup.slnf", "csharp.debug.console": "integratedTerminal", "debug.terminal.clearBeforeReusing": true, "editor.formatOnSave": true, From fb9e749e0533bacd7480a09aff776cf021c5b3d9 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 14:41:45 -0800 Subject: [PATCH 10/59] include more specific error details + dev tag for telem we should investigate if the error mapping can be outsourced as it seems silly we need to implement this ourselves --- .../Internal/ReleaseManifest.cs | 4 +- src/Installer/dotnetup/.vscode/launch.json | 6 +- src/Installer/dotnetup/CommandBase.cs | 18 ++ .../Commands/Info/InfoCommandParser.cs | 3 +- .../Commands/Sdk/Install/SdkInstallCommand.cs | 11 +- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 27 +- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 291 ++++++++++++++++-- .../Telemetry/TelemetryCommonProperties.cs | 22 +- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 279 +++++++++++++++++ test/dotnetup.Tests/InfoCommandTests.cs | 26 +- .../dotnetup.Tests/Properties/AssemblyInfo.cs | 20 ++ test/dotnetup.Tests/TelemetryTests.cs | 269 ++++++++++++++++ 12 files changed, 937 insertions(+), 39 deletions(-) create mode 100644 test/dotnetup.Tests/ErrorCodeMapperTests.cs create mode 100644 test/dotnetup.Tests/TelemetryTests.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 033e03ab10f1..77885ce2038f 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -29,13 +29,13 @@ public ReleaseManifest() try { var productCollection = GetReleasesIndex(); - var product = FindProduct(productCollection, resolvedVersion) + var product = FindProduct(productCollection, resolvedVersion) ?? throw new DotnetInstallException( DotnetInstallErrorCode.VersionNotFound, $"No product found for version {resolvedVersion}", version: resolvedVersion.ToString(), component: installRequest.Component.ToString()); - var release = FindRelease(product, resolvedVersion, installRequest.Component) + var release = FindRelease(product, resolvedVersion, installRequest.Component) ?? throw new DotnetInstallException( DotnetInstallErrorCode.ReleaseNotFound, $"No release found for version {resolvedVersion}", diff --git a/src/Installer/dotnetup/.vscode/launch.json b/src/Installer/dotnetup/.vscode/launch.json index 5e1eb1d116c4..6da023bb2fad 100644 --- a/src/Installer/dotnetup/.vscode/launch.json +++ b/src/Installer/dotnetup/.vscode/launch.json @@ -10,7 +10,11 @@ "args": "${input:commandLineArgs}", "cwd": "${workspaceFolder}", "console": "integratedTerminal", - "stopAtEntry": false + "stopAtEntry": false, + "env": { + "DOTNET_CLI_TELEMETRY_OPTOUT": "", + "DOTNETUP_DEV_BUILD": "1" + } }, { "name": ".NET Core Attach", diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index 4d83cf77c194..a9454e6bc7d7 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -49,7 +49,10 @@ public int Execute() { stopwatch.Stop(); _commandActivity?.SetTag("duration_ms", stopwatch.Elapsed.TotalMilliseconds); + _commandActivity?.SetTag("exit.code", 1); DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); + // Activity status is set inside RecordException, but explicitly set it here too + _commandActivity?.SetStatus(ActivityStatusCode.Error, ex.Message); throw; } finally @@ -111,4 +114,19 @@ protected void RecordRequestedVersion(string? versionOrChannel) var sanitized = VersionSanitizer.Sanitize(versionOrChannel); _commandActivity?.SetTag("sdk.requested_version", sanitized); } + + /// + /// Records the source of the SDK request (explicit user input vs default). + /// + /// The request source: "explicit", "default-latest", or "default-globaljson". + /// The sanitized requested value (channel/version). For defaults, this is what was defaulted to. + protected void RecordRequestSource(string source, string? requestedValue) + { + _commandActivity?.SetTag("sdk.request_source", source); + if (requestedValue != null) + { + var sanitized = VersionSanitizer.Sanitize(requestedValue); + _commandActivity?.SetTag("sdk.requested", sanitized); + } + } } diff --git a/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs b/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs index 11d3299d9983..42f4d8922f82 100644 --- a/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs @@ -33,7 +33,8 @@ private static Command ConstructCommand() { var jsonOutput = parseResult.GetValue(JsonOption); var noList = parseResult.GetValue(NoListOption); - return new InfoCommand(parseResult, jsonOutput, noList).Execute(); + var infoCommand = new Info.InfoCommand(parseResult, jsonOutput, noList); + return infoCommand.Execute(); }); return command; diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs index 4c60462b3c70..4b83d286c728 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -31,7 +31,7 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) protected override int ExecuteCore() { - // Record the requested version with PII sanitization + // Record the raw requested version with PII sanitization (for backwards compatibility) RecordRequestedVersion(_versionOrChannel); var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); @@ -104,16 +104,19 @@ protected override int ExecuteCore() } string? resolvedChannel = null; + string requestSource; if (channelFromGlobalJson is not null) { SpectreAnsiConsole.WriteLine($".NET SDK {channelFromGlobalJson} will be installed since {globalJsonInfo?.GlobalJsonPath} specifies that version."); resolvedChannel = channelFromGlobalJson; + requestSource = "default-globaljson"; } else if (_versionOrChannel is not null) { resolvedChannel = _versionOrChannel; + requestSource = "explicit"; } else { @@ -126,13 +129,19 @@ protected override int ExecuteCore() resolvedChannel = SpectreAnsiConsole.Prompt( new TextPrompt("Which channel of the .NET SDK do you want to install?") .DefaultValue(ChannelVersionResolver.LatestChannel)); + // User selected interactively, treat as explicit + requestSource = "explicit"; } else { resolvedChannel = ChannelVersionResolver.LatestChannel; // Default to latest if no channel is specified + requestSource = "default-latest"; } } + // Record the request source and the resolved requested value + RecordRequestSource(requestSource, resolvedChannel); + bool? resolvedSetDefaultInstall = _setDefaultInstall; if (resolvedSetDefaultInstall == null) diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index 6313add532d6..b348abd1d840 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -160,6 +160,21 @@ public void RecordException(Activity? activity, Exception ex, string? errorCode activity.SetTag("error.hresult", errorInfo.HResult.Value); } + if (errorInfo.Details is not null) + { + activity.SetTag("error.details", errorInfo.Details); + } + + if (errorInfo.SourceLocation is not null) + { + activity.SetTag("error.source_location", errorInfo.SourceLocation); + } + + if (errorInfo.ExceptionChain is not null) + { + activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); + } + activity.RecordException(ex); } @@ -227,9 +242,17 @@ public void PostInstallEvent(InstallEventData data) /// /// Flushes any pending telemetry. /// - public void Flush() + /// Maximum time to wait for flush (default 5 seconds). + public void Flush(int timeoutMilliseconds = 5000) { - _tracerProvider?.ForceFlush(); + try + { + _tracerProvider?.ForceFlush(timeoutMilliseconds); + } + catch + { + // Never let telemetry flush failures crash the app + } } /// diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index c6ecb4805442..9976c272b7d0 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -1,7 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; +using System.Diagnostics; using System.Net; +using System.Runtime.InteropServices; using Microsoft.Dotnet.Installation; namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; @@ -12,12 +15,16 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// The error type/code for telemetry. /// HTTP status code if applicable. /// Win32 HResult if applicable. -/// Additional context like file path. +/// Additional context (no PII - sanitized values only). +/// Method name from our code where error occurred (no file paths). +/// Chain of exception types for wrapped exceptions. public sealed record ExceptionErrorInfo( string ErrorType, int? StatusCode = null, int? HResult = null, - string? Details = null); + string? Details = null, + string? SourceLocation = null, + string? ExceptionChain = null); /// /// Maps exceptions to error info for telemetry. @@ -43,51 +50,295 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) return GetErrorInfo(ex.InnerException); } + // Get common enrichment data + var sourceLocation = GetSafeSourceLocation(ex); + var exceptionChain = GetExceptionChain(ex); + return ex switch { // DotnetInstallException has specific error codes DotnetInstallException installEx => new ExceptionErrorInfo( installEx.ErrorCode.ToString(), - Details: installEx.Version), + Details: installEx.Version, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), HttpRequestException httpEx => new ExceptionErrorInfo( httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", - StatusCode: (int?)httpEx.StatusCode), + StatusCode: (int?)httpEx.StatusCode, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), // FileNotFoundException before IOException (it derives from IOException) FileNotFoundException fnfEx => new ExceptionErrorInfo( "FileNotFound", HResult: fnfEx.HResult, - Details: fnfEx.FileName is not null ? "file_specified" : null), + Details: fnfEx.FileName is not null ? "file_specified" : null, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), - UnauthorizedAccessException => new ExceptionErrorInfo("PermissionDenied"), + UnauthorizedAccessException => new ExceptionErrorInfo( + "PermissionDenied", + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), - DirectoryNotFoundException => new ExceptionErrorInfo("DirectoryNotFound"), + DirectoryNotFoundException => new ExceptionErrorInfo( + "DirectoryNotFound", + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), - IOException ioEx => MapIOException(ioEx), + IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain), - OperationCanceledException => new ExceptionErrorInfo("Cancelled"), + OperationCanceledException => new ExceptionErrorInfo( + "Cancelled", + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), ArgumentException argEx => new ExceptionErrorInfo( "InvalidArgument", - Details: argEx.ParamName), + Details: argEx.ParamName, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + InvalidOperationException => new ExceptionErrorInfo( + "InvalidOperation", + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + NotSupportedException => new ExceptionErrorInfo( + "NotSupported", + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), - _ => new ExceptionErrorInfo(ex.GetType().Name) + TimeoutException => new ExceptionErrorInfo( + "Timeout", + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + _ => new ExceptionErrorInfo( + ex.GetType().Name, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain) }; } - private static ExceptionErrorInfo MapIOException(IOException ioEx) + private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain) { - // Check for common HResult values - const int ERROR_DISK_FULL = unchecked((int)0x80070070); - const int ERROR_HANDLE_DISK_FULL = unchecked((int)0x80070027); - const int ERROR_ACCESS_DENIED = unchecked((int)0x80070005); + string errorType; + string? details; + + // On Windows, use Win32Exception to get the readable error message + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && ioEx.HResult != 0) + { + // Extract the Win32 error code from HResult (lower 16 bits) + var win32ErrorCode = ioEx.HResult & 0xFFFF; + var win32Ex = new Win32Exception(win32ErrorCode); + details = win32Ex.Message; - return ioEx.HResult switch + // Derive a short error type from the HResult + errorType = GetWindowsErrorType(ioEx.HResult); + } + else { - ERROR_DISK_FULL or ERROR_HANDLE_DISK_FULL => new ExceptionErrorInfo("DiskFull", HResult: ioEx.HResult), - ERROR_ACCESS_DENIED => new ExceptionErrorInfo("PermissionDenied", HResult: ioEx.HResult), - _ => new ExceptionErrorInfo("IOException", HResult: ioEx.HResult) + // On non-Windows or if no HResult, use our mapping + (errorType, details) = GetErrorTypeFromHResult(ioEx.HResult); + } + + return new ExceptionErrorInfo( + errorType, + HResult: ioEx.HResult, + Details: details, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain); + } + + /// + /// Gets a short error type name from a Windows HResult. + /// + private static string GetWindowsErrorType(int hResult) + { + return hResult switch + { + unchecked((int)0x80070070) or unchecked((int)0x80070027) => "DiskFull", + unchecked((int)0x80070005) => "PermissionDenied", + unchecked((int)0x80070020) => "SharingViolation", + unchecked((int)0x80070021) => "LockViolation", + unchecked((int)0x800700CE) => "PathTooLong", + unchecked((int)0x8007007B) => "InvalidPath", + unchecked((int)0x80070003) => "PathNotFound", + unchecked((int)0x80070002) => "FileNotFound", + unchecked((int)0x800700B7) => "AlreadyExists", + unchecked((int)0x80070050) => "FileExists", + unchecked((int)0x80070035) => "NetworkPathNotFound", + unchecked((int)0x80070033) => "NetworkNameDeleted", + unchecked((int)0x80004005) => "GeneralFailure", + unchecked((int)0x8007001F) => "DeviceFailure", + unchecked((int)0x80070057) => "InvalidParameter", + unchecked((int)0x80070079) => "SemaphoreTimeout", + _ => "IOException" }; } + + /// + /// Gets error type and details from HResult for non-Windows platforms. + /// + private static (string errorType, string? details) GetErrorTypeFromHResult(int hResult) + { + return hResult switch + { + // Disk/storage errors + unchecked((int)0x80070070) => ("DiskFull", "ERROR_DISK_FULL"), + unchecked((int)0x80070027) => ("DiskFull", "ERROR_HANDLE_DISK_FULL"), + unchecked((int)0x80070079) => ("SemaphoreTimeout", "ERROR_SEM_TIMEOUT"), + + // Permission errors + unchecked((int)0x80070005) => ("PermissionDenied", "ERROR_ACCESS_DENIED"), + unchecked((int)0x80070020) => ("SharingViolation", "ERROR_SHARING_VIOLATION"), + unchecked((int)0x80070021) => ("LockViolation", "ERROR_LOCK_VIOLATION"), + + // Path errors + unchecked((int)0x800700CE) => ("PathTooLong", "ERROR_FILENAME_EXCED_RANGE"), + unchecked((int)0x8007007B) => ("InvalidPath", "ERROR_INVALID_NAME"), + unchecked((int)0x80070003) => ("PathNotFound", "ERROR_PATH_NOT_FOUND"), + unchecked((int)0x80070002) => ("FileNotFound", "ERROR_FILE_NOT_FOUND"), + + // File/directory existence errors + unchecked((int)0x800700B7) => ("AlreadyExists", "ERROR_ALREADY_EXISTS"), + unchecked((int)0x80070050) => ("FileExists", "ERROR_FILE_EXISTS"), + + // Network errors + unchecked((int)0x80070035) => ("NetworkPathNotFound", "ERROR_BAD_NETPATH"), + unchecked((int)0x80070033) => ("NetworkNameDeleted", "ERROR_NETNAME_DELETED"), + unchecked((int)0x80004005) => ("GeneralFailure", "E_FAIL"), + + // Device/hardware errors + unchecked((int)0x8007001F) => ("DeviceFailure", "ERROR_GEN_FAILURE"), + unchecked((int)0x80070057) => ("InvalidParameter", "ERROR_INVALID_PARAMETER"), + + // Default: include raw HResult for debugging + _ => ("IOException", hResult != 0 ? $"0x{hResult:X8}" : null) + }; + } + + /// + /// Gets a safe source location from the stack trace - finds the first frame from our assemblies. + /// This is typically the code in dotnetup that called into BCL/external code that threw. + /// No file paths or line numbers that could contain user info. + /// + private static string? GetSafeSourceLocation(Exception ex) + { + try + { + var stackTrace = new StackTrace(ex, fNeedFileInfo: false); + var frames = stackTrace.GetFrames(); + + if (frames == null || frames.Length == 0) + { + return null; + } + + string? throwSite = null; + + // Walk the stack from throw site upward, looking for the first frame in our code. + // This finds the dotnetup code that called into BCL/external code that threw. + foreach (var frame in frames) + { + var methodInfo = DiagnosticMethodInfo.Create(frame); + if (methodInfo == null) continue; + + // DiagnosticMethodInfo provides DeclaringTypeName which includes the full type name + var declaringType = methodInfo.DeclaringTypeName; + if (string.IsNullOrEmpty(declaringType)) continue; + + // Capture the first frame as the throw site (fallback) + if (throwSite == null) + { + var throwTypeName = ExtractTypeName(declaringType); + throwSite = $"[BCL]{throwTypeName}.{methodInfo.Name}"; + } + + // Check if it's from our assemblies by looking at the namespace prefix + if (IsOwnedNamespace(declaringType)) + { + // Extract just the type name (last part after the last dot, before any generic params) + var typeName = ExtractTypeName(declaringType); + + // Return "TypeName.MethodName" - no paths, no line numbers + return $"{typeName}.{methodInfo.Name}"; + } + } + + // If we didn't find our code, return the throw site as a fallback + return throwSite; + } + catch + { + // Never fail telemetry due to stack trace parsing + return null; + } + } + + /// + /// Checks if a type name belongs to one of our owned namespaces. + /// + private static bool IsOwnedNamespace(string declaringType) + { + return declaringType.StartsWith("Microsoft.DotNet.Tools.Bootstrapper", StringComparison.Ordinal) || + declaringType.StartsWith("Microsoft.Dotnet.Installation", StringComparison.Ordinal); + } + + /// + /// Extracts just the type name from a fully qualified type name. + /// + private static string ExtractTypeName(string fullTypeName) + { + var typeName = fullTypeName; + var lastDot = typeName.LastIndexOf('.'); + if (lastDot >= 0) + { + typeName = typeName.Substring(lastDot + 1); + } + // Remove generic arity if present (e.g., "List`1" -> "List") + var genericMarker = typeName.IndexOf('`'); + if (genericMarker >= 0) + { + typeName = typeName.Substring(0, genericMarker); + } + return typeName; + } + + /// + /// Gets the exception type chain for wrapped exceptions. + /// Example: "HttpRequestException->SocketException" + /// + private static string? GetExceptionChain(Exception ex) + { + if (ex.InnerException == null) + { + return null; + } + + try + { + var types = new List { ex.GetType().Name }; + var inner = ex.InnerException; + + // Limit depth to prevent infinite loops and overly long strings + const int maxDepth = 5; + var depth = 0; + + while (inner != null && depth < maxDepth) + { + types.Add(inner.GetType().Name); + inner = inner.InnerException; + depth++; + } + + return string.Join("->", types); + } + catch + { + return null; + } + } } diff --git a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs index 852d506bd069..5e82008978a8 100644 --- a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs +++ b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs @@ -16,6 +16,12 @@ internal static class TelemetryCommonProperties { private static readonly Lazy s_deviceId = new(GetDeviceId); private static readonly Lazy s_isCIEnvironment = new(DetectCIEnvironment); + private static readonly Lazy s_isDevBuild = new(DetectDevBuild); + + /// + /// Environment variable to mark telemetry as coming from a dev build. + /// + private const string DevBuildEnvVar = "DOTNETUP_DEV_BUILD"; /// /// Gets common attributes for the OpenTelemetry resource. @@ -30,7 +36,8 @@ public static IEnumerable> GetCommonAttributes(stri ["os.version"] = Environment.OSVersion.VersionString, ["process.arch"] = RuntimeInformation.ProcessArchitecture.ToString(), ["ci.detected"] = s_isCIEnvironment.Value, - ["dotnetup.version"] = GetVersion() + ["dotnetup.version"] = GetVersion(), + ["dev.build"] = s_isDevBuild.Value }; } @@ -102,6 +109,19 @@ private static bool DetectCIEnvironment() } } + private static bool DetectDevBuild() + { + // Debug builds are always considered dev builds +#if DEBUG + return true; +#else + // Check for DOTNETUP_DEV_BUILD environment variable (for release builds in dev scenarios) + var devBuildValue = Environment.GetEnvironmentVariable(DevBuildEnvVar); + return string.Equals(devBuildValue, "1", StringComparison.Ordinal) || + string.Equals(devBuildValue, "true", StringComparison.OrdinalIgnoreCase); +#endif + } + private static string GetVersion() { return typeof(TelemetryCommonProperties).Assembly diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs new file mode 100644 index 000000000000..7ff7f6a6543d --- /dev/null +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -0,0 +1,279 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +using Xunit; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Tests; + +public class ErrorCodeMapperTests +{ + [Fact] + public void GetErrorInfo_IOException_DiskFull_MapsCorrectly() + { + // HResult for ERROR_DISK_FULL + var ex = new IOException("Not enough space", unchecked((int)0x80070070)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("DiskFull", info.ErrorType); + Assert.Equal(unchecked((int)0x80070070), info.HResult); + // On Windows, we get the readable message from Win32Exception + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Contains("not enough space", info.Details, StringComparison.OrdinalIgnoreCase); + } + else + { + Assert.Equal("ERROR_DISK_FULL", info.Details); + } + } + + [Fact] + public void GetErrorInfo_IOException_SharingViolation_MapsCorrectly() + { + // HResult for ERROR_SHARING_VIOLATION + var ex = new IOException("File in use", unchecked((int)0x80070020)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("SharingViolation", info.ErrorType); + // On Windows, we get the readable message + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.NotNull(info.Details); + Assert.NotEmpty(info.Details!); + } + else + { + Assert.Equal("ERROR_SHARING_VIOLATION", info.Details); + } + } + + [Fact] + public void GetErrorInfo_IOException_PathTooLong_MapsCorrectly() + { + var ex = new IOException("Path too long", unchecked((int)0x800700CE)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("PathTooLong", info.ErrorType); + // On Windows, we get the readable message + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Contains("too long", info.Details, StringComparison.OrdinalIgnoreCase); + } + else + { + Assert.Equal("ERROR_FILENAME_EXCED_RANGE", info.Details); + } + } + + [Fact] + public void GetErrorInfo_IOException_UnknownHResult_IncludesHexValue() + { + var ex = new IOException("Unknown error", unchecked((int)0x80071234)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("IOException", info.ErrorType); + // On Windows, Win32Exception provides a message even for unknown errors + // On other platforms, we fall back to hex + Assert.NotNull(info.Details); + } + + [Fact] + public void GetErrorInfo_HttpRequestException_WithStatusCode_MapsCorrectly() + { + var ex = new HttpRequestException("Not found", null, HttpStatusCode.NotFound); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("Http404", info.ErrorType); + Assert.Equal(404, info.StatusCode); + } + + [Fact] + public void GetErrorInfo_WrappedException_IncludesChain() + { + var innerInner = new SocketException(10054); // Connection reset + var inner = new IOException("Network error", innerInner); + var outer = new HttpRequestException("Request failed", inner); + + var info = ErrorCodeMapper.GetErrorInfo(outer); + + Assert.Equal("HttpRequestException", info.ErrorType); + Assert.Equal("HttpRequestException->IOException->SocketException", info.ExceptionChain); + } + + [Fact] + public void GetErrorInfo_AggregateException_UnwrapsSingleInner() + { + var inner = new FileNotFoundException("Missing file"); + var aggregate = new AggregateException(inner); + + var info = ErrorCodeMapper.GetErrorInfo(aggregate); + + Assert.Equal("FileNotFound", info.ErrorType); + } + + [Fact] + public void GetErrorInfo_TimeoutException_MapsCorrectly() + { + var ex = new TimeoutException("Operation timed out"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("Timeout", info.ErrorType); + } + + [Fact] + public void GetErrorInfo_InvalidOperationException_MapsCorrectly() + { + var ex = new InvalidOperationException("Invalid state"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("InvalidOperation", info.ErrorType); + } + + [Fact] + public void GetErrorInfo_ExceptionFromOurCode_IncludesSourceLocation() + { + // Throw from a method to get a real stack trace + var ex = ThrowTestException(); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + // Source location is only populated for our owned assemblies (dotnetup, Microsoft.Dotnet.Installation) + // In tests, we won't have those on the stack, so source location will be null + // The important thing is that the method doesn't throw + Assert.Equal("InvalidOperation", info.ErrorType); + } + + [Fact] + public void GetErrorInfo_SourceLocation_FiltersToOwnedNamespaces() + { + // Verify that source location filtering works by namespace prefix + // We must throw and catch to get a stack trace - exceptions created with 'new' have no trace + var ex = ThrowTestException(); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + // Source location should be populated since test assembly is in an owned namespace + // (Microsoft.DotNet.Tools.Bootstrapper.Tests starts with Microsoft.DotNet.Tools.Bootstrapper) + Assert.NotNull(info.SourceLocation); + // The format is "TypeName.MethodName" - no [BCL] prefix since we found owned code + Assert.DoesNotContain("[BCL]", info.SourceLocation); + Assert.Contains("ThrowTestException", info.SourceLocation); + } + + [Fact] + public void GetErrorInfo_AllFieldsPopulated_ForIOExceptionWithChain() + { + // Create a realistic exception scenario - IOException with inner exception + var inner = new UnauthorizedAccessException("Access denied"); + var outer = new IOException("Cannot write file", inner); + + var info = ErrorCodeMapper.GetErrorInfo(outer); + + // Verify exception chain is populated + Assert.Equal("IOException", info.ErrorType); + Assert.Equal("IOException->UnauthorizedAccessException", info.ExceptionChain); + } + + [Fact] + public void GetErrorInfo_HResultAndDetails_ForDiskFullException() + { + // Create IOException with specific HResult for disk full + var ex = new IOException("Disk full", unchecked((int)0x80070070)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("DiskFull", info.ErrorType); + Assert.Equal(unchecked((int)0x80070070), info.HResult); + // On Windows, we get the readable message from Win32Exception + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Contains("not enough space", info.Details, StringComparison.OrdinalIgnoreCase); + } + else + { + Assert.Equal("ERROR_DISK_FULL", info.Details); + } + } + + private static Exception ThrowTestException() + { + try + { + throw new InvalidOperationException("Test exception"); + } + catch (Exception ex) + { + return ex; + } + } + + [Fact] + public void GetErrorInfo_LongExceptionChain_LimitsDepth() + { + // Create a chain of typed exceptions (not plain Exception which gets unwrapped) + Exception ex = new InvalidOperationException("Root"); + for (int i = 0; i < 10; i++) + { + ex = new IOException($"Wrapper {i}", ex); + } + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + // Should have an exception chain since we're using IOException wrappers + Assert.NotNull(info.ExceptionChain); + var parts = info.ExceptionChain!.Split("->"); + // Chain is limited to maxDepth (5) + 1 for the outer exception = 6 + Assert.True(parts.Length <= 6, $"Chain too long: {info.ExceptionChain}"); + } + + [Fact] + public void GetErrorInfo_NetworkPathNotFound_MapsCorrectly() + { + var ex = new IOException("Network path not found", unchecked((int)0x80070035)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("NetworkPathNotFound", info.ErrorType); + // On Windows, we get the readable message + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Contains("network path", info.Details, StringComparison.OrdinalIgnoreCase); + } + else + { + Assert.Equal("ERROR_BAD_NETPATH", info.Details); + } + } + + [Fact] + public void GetErrorInfo_AlreadyExists_MapsCorrectly() + { + // HResult for ERROR_ALREADY_EXISTS (0x800700B7 = -2147024713) + var ex = new IOException("Cannot create a file when that file already exists", unchecked((int)0x800700B7)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("AlreadyExists", info.ErrorType); + Assert.Equal(unchecked((int)0x800700B7), info.HResult); + // On Windows, we get the readable message + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Contains("already exists", info.Details, StringComparison.OrdinalIgnoreCase); + } + else + { + Assert.Equal("ERROR_ALREADY_EXISTS", info.Details); + } + } +} diff --git a/test/dotnetup.Tests/InfoCommandTests.cs b/test/dotnetup.Tests/InfoCommandTests.cs index 52902ce24a7f..e86d3f811e76 100644 --- a/test/dotnetup.Tests/InfoCommandTests.cs +++ b/test/dotnetup.Tests/InfoCommandTests.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.CommandLine.Parsing; using System.Text.Json; using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Info; @@ -9,16 +10,19 @@ namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public class InfoCommandTests { + /// + /// Creates an InfoCommand instance with the given parameters. + /// private static InfoCommand CreateInfoCommand(bool jsonOutput, bool noList, TextWriter output) { - var args = new List { "--info" }; - if (jsonOutput) args.Add("--json"); - if (noList) args.Add("--no-list"); - - var parseResult = Parser.Parse(args.ToArray()); + // Create a minimal ParseResult for the command + var parseResult = Parser.Parse(new[] { "--info" }); return new InfoCommand(parseResult, jsonOutput, noList, output); } + /// + /// Executes the InfoCommand and returns the exit code. + /// private static int ExecuteInfoCommand(bool jsonOutput, bool noList, TextWriter output) { var command = CreateInfoCommand(jsonOutput, noList, output); @@ -94,7 +98,7 @@ public void InfoCommand_ShouldReturnZeroExitCode(bool jsonOutput) using var sw = new StringWriter(); // Act - use noList: true to avoid manifest access in unit tests - var exitCode = ExecuteInfoCommand(jsonOutput: jsonOutput, noList: true, output: sw); + var exitCode = ExecuteInfoCommand(jsonOutput, noList: true, sw); // Assert exitCode.Should().Be(0); @@ -107,7 +111,7 @@ public void InfoCommand_HumanReadable_ShouldOutputExpectedFormat() using var sw = new StringWriter(); // Act - use noList: true to avoid manifest access in unit tests - ExecuteInfoCommand(jsonOutput: false, noList: true, output: sw); + ExecuteInfoCommand(jsonOutput: false, noList: true, sw); var output = sw.ToString(); // Assert @@ -125,7 +129,7 @@ public void InfoCommand_HumanReadable_WithList_ShouldIncludeListOutput() using var sw = new StringWriter(); // Act - include list (may be empty but should show the header) - ExecuteInfoCommand(jsonOutput: false, noList: false, output: sw); + ExecuteInfoCommand(jsonOutput: false, noList: false, sw); var output = sw.ToString(); // Assert @@ -141,7 +145,7 @@ public void InfoCommand_Json_ShouldOutputValidJson() using var sw = new StringWriter(); // Act - use noList: true to avoid manifest access in unit tests - ExecuteInfoCommand(jsonOutput: true, noList: true, output: sw); + ExecuteInfoCommand(jsonOutput: true, noList: true, sw); var output = sw.ToString(); // Assert - should be valid JSON @@ -156,7 +160,7 @@ public void InfoCommand_Json_ShouldContainExpectedProperties() using var sw = new StringWriter(); // Act - use noList: true to avoid manifest access in unit tests - ExecuteInfoCommand(jsonOutput: true, noList: true, output: sw); + ExecuteInfoCommand(jsonOutput: true, noList: true, sw); var output = sw.ToString(); // Assert @@ -176,7 +180,7 @@ public void InfoCommand_Json_WithList_ShouldContainInstallationsProperty() using var sw = new StringWriter(); // Act - include list - ExecuteInfoCommand(jsonOutput: true, noList: false, output: sw); + ExecuteInfoCommand(jsonOutput: true, noList: false, sw); var output = sw.ToString(); // Assert diff --git a/test/dotnetup.Tests/Properties/AssemblyInfo.cs b/test/dotnetup.Tests/Properties/AssemblyInfo.cs index 7173633619d7..9278f07c4daa 100644 --- a/test/dotnetup.Tests/Properties/AssemblyInfo.cs +++ b/test/dotnetup.Tests/Properties/AssemblyInfo.cs @@ -1,8 +1,28 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Xunit; // Enable parallel test execution [assembly: CollectionBehavior(CollectionBehavior.CollectionPerClass, DisableTestParallelization = false, MaxParallelThreads = 0)] + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +/// +/// Module initializer to configure test environment before any tests run. +/// +internal static class TestModuleInitializer +{ + /// + /// Sets up environment variables for test runs. + /// This ensures telemetry from tests is marked as dev builds. + /// + [ModuleInitializer] + internal static void Initialize() + { + // Mark all test runs as dev builds so telemetry doesn't pollute production data + Environment.SetEnvironmentVariable("DOTNETUP_DEV_BUILD", "1"); + } +} diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs new file mode 100644 index 000000000000..07d6607f0a9f --- /dev/null +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -0,0 +1,269 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +using Xunit; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Tests; + +public class TelemetryCommonPropertiesTests +{ + [Fact] + public void Hash_SameInput_ProducesSameOutput() + { + var input = "test-string"; + var hash1 = TelemetryCommonProperties.Hash(input); + var hash2 = TelemetryCommonProperties.Hash(input); + + Assert.Equal(hash1, hash2); + } + + [Fact] + public void Hash_DifferentInputs_ProduceDifferentOutputs() + { + var hash1 = TelemetryCommonProperties.Hash("input1"); + var hash2 = TelemetryCommonProperties.Hash("input2"); + + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public void Hash_ReturnsValidSha256_64CharHex() + { + var hash = TelemetryCommonProperties.Hash("test"); + + // SHA256 produces 32 bytes = 64 hex characters + Assert.Equal(64, hash.Length); + Assert.Matches("^[a-f0-9]+$", hash); + } + + [Fact] + public void Hash_IsLowercase() + { + var hash = TelemetryCommonProperties.Hash("TEST"); + + Assert.Equal(hash.ToLowerInvariant(), hash); + } + + [Fact] + public void HashPath_NullPath_ReturnsEmpty() + { + var result = TelemetryCommonProperties.HashPath(null); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void HashPath_EmptyPath_ReturnsEmpty() + { + var result = TelemetryCommonProperties.HashPath(string.Empty); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void HashPath_ValidPath_ReturnsHash() + { + var result = TelemetryCommonProperties.HashPath(@"C:\Users\test\path"); + + Assert.NotEmpty(result); + Assert.Equal(64, result.Length); + } + + [Fact] + public void GetCommonAttributes_ContainsRequiredKeys() + { + var sessionId = Guid.NewGuid().ToString(); + var attributes = TelemetryCommonProperties.GetCommonAttributes(sessionId) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + Assert.Contains("session.id", attributes.Keys); + Assert.Contains("device.id", attributes.Keys); + Assert.Contains("os.platform", attributes.Keys); + Assert.Contains("os.version", attributes.Keys); + Assert.Contains("process.arch", attributes.Keys); + Assert.Contains("ci.detected", attributes.Keys); + Assert.Contains("dotnetup.version", attributes.Keys); + Assert.Contains("dev.build", attributes.Keys); + } + + [Fact] + public void GetCommonAttributes_SessionIdMatchesInput() + { + var sessionId = Guid.NewGuid().ToString(); + var attributes = TelemetryCommonProperties.GetCommonAttributes(sessionId) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + Assert.Equal(sessionId, attributes["session.id"]); + } + + [Fact] + public void GetCommonAttributes_OsPlatformIsValid() + { + var attributes = TelemetryCommonProperties.GetCommonAttributes("test-session") + .ToDictionary(kv => kv.Key, kv => kv.Value); + + var osPlatform = attributes["os.platform"] as string; + Assert.Contains(osPlatform, new[] { "Windows", "Linux", "macOS", "Unknown" }); + } + + [Fact] + public void GetCommonAttributes_ProcessArchIsValid() + { + var attributes = TelemetryCommonProperties.GetCommonAttributes("test-session") + .ToDictionary(kv => kv.Key, kv => kv.Value); + + var arch = attributes["process.arch"] as string; + Assert.Contains(arch, new[] { "X86", "X64", "Arm", "Arm64", "Wasm", "S390x", "LoongArch64", "Armv6", "Ppc64le", "RiscV64" }); + } + + [Fact] + public void GetCommonAttributes_DeviceIdIsNotEmpty() + { + var attributes = TelemetryCommonProperties.GetCommonAttributes("test-session") + .ToDictionary(kv => kv.Key, kv => kv.Value); + + var deviceId = attributes["device.id"] as string; + Assert.False(string.IsNullOrEmpty(deviceId)); + } + + [Fact] + public void GetCommonAttributes_VersionIsNotEmpty() + { + var attributes = TelemetryCommonProperties.GetCommonAttributes("test-session") + .ToDictionary(kv => kv.Key, kv => kv.Value); + + var version = attributes["dotnetup.version"] as string; + Assert.False(string.IsNullOrEmpty(version)); + } +} + +public class VersionSanitizerTelemetryTests +{ + [Theory] + [InlineData("9.0", "9.0")] + [InlineData("9.0.100", "9.0.100")] + [InlineData("10.0.1xx", "10.0.1xx")] + [InlineData("latest", "latest")] + [InlineData("preview", "preview")] + [InlineData("lts", "lts")] + [InlineData("sts", "sts")] + public void Sanitize_ValidVersions_PassThrough(string input, string expected) + { + var result = VersionSanitizer.Sanitize(input); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("my-custom-path")] + [InlineData("/home/user/sdk")] + [InlineData("C:\\Users\\secret\\path")] + [InlineData("some random text")] + public void Sanitize_InvalidInput_ReturnsInvalid(string input) + { + var result = VersionSanitizer.Sanitize(input); + + Assert.Equal("invalid", result); + } + + [Theory] + [InlineData("9.0.100-preview.1")] + [InlineData("9.0.100-rc.2")] + [InlineData("10.0.100-preview.3.25678.9")] + public void Sanitize_PreReleaseVersions_PassThrough(string input) + { + var result = VersionSanitizer.Sanitize(input); + + Assert.Equal(input, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Sanitize_NullOrEmpty_ReturnsUnspecified(string? input) + { + var result = VersionSanitizer.Sanitize(input); + + Assert.Equal("unspecified", result); + } + + [Theory] + [InlineData("10.0.10xx")] // Two digits before xx not allowed + [InlineData("10.0.100x")] // Three digits before x not allowed + [InlineData("10.0.xxxx")] // Too many x's + public void Sanitize_InvalidWildcards_ReturnsInvalid(string input) + { + var result = VersionSanitizer.Sanitize(input); + + Assert.Equal("invalid", result); + } + + [Theory] + [InlineData("10.0.1xx")] // Feature band wildcard + [InlineData("10.0.20x")] // Single digit wildcard + public void Sanitize_ValidWildcards_PassThrough(string input) + { + var result = VersionSanitizer.Sanitize(input); + + Assert.Equal(input, result); + } +} + +public class DotnetupTelemetryTests +{ + [Fact] + public void Instance_ReturnsSameInstance() + { + var instance1 = DotnetupTelemetry.Instance; + var instance2 = DotnetupTelemetry.Instance; + + Assert.Same(instance1, instance2); + } + + [Fact] + public void SessionId_IsValidGuid() + { + var sessionId = DotnetupTelemetry.Instance.SessionId; + + Assert.True(Guid.TryParse(sessionId, out _)); + } + + [Fact] + public void CommandSource_IsNotNull() + { + Assert.NotNull(DotnetupTelemetry.CommandSource); + } + + [Fact] + public void CommandSource_HasCorrectName() + { + Assert.Equal("Microsoft.Dotnet.Bootstrapper", DotnetupTelemetry.CommandSource.Name); + } + + [Fact] + public void Flush_DoesNotThrow() + { + // Even if telemetry is disabled, Flush should not throw + var exception = Record.Exception(() => DotnetupTelemetry.Instance.Flush()); + + Assert.Null(exception); + } + + [Fact] + public void Flush_WithTimeout_DoesNotThrow() + { + var exception = Record.Exception(() => DotnetupTelemetry.Instance.Flush(1000)); + + Assert.Null(exception); + } + + [Fact] + public void RecordException_WithNullActivity_DoesNotThrow() + { + var exception = Record.Exception(() => + DotnetupTelemetry.Instance.RecordException(null, new Exception("test"))); + + Assert.Null(exception); + } +} From 66896442a9f6ec44ae752956232df265146b2d4b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 15:15:44 -0800 Subject: [PATCH 11/59] first run notice + library hook guidance + tests --- .../Internal/InstallationActivitySource.cs | 39 ++++++ .../dotnetup/NonUpdatingProgressTarget.cs | 40 ++++++- src/Installer/dotnetup/Program.cs | 3 + .../dotnetup/SpectreProgressTarget.cs | 40 ++++++- src/Installer/dotnetup/Strings.resx | 3 + .../dotnetup/Telemetry/FirstRunNotice.cs | 111 ++++++++++++++++++ src/Installer/dotnetup/xlf/Strings.cs.xlf | 5 + src/Installer/dotnetup/xlf/Strings.de.xlf | 5 + src/Installer/dotnetup/xlf/Strings.es.xlf | 5 + src/Installer/dotnetup/xlf/Strings.fr.xlf | 5 + src/Installer/dotnetup/xlf/Strings.it.xlf | 5 + src/Installer/dotnetup/xlf/Strings.ja.xlf | 5 + src/Installer/dotnetup/xlf/Strings.ko.xlf | 5 + src/Installer/dotnetup/xlf/Strings.pl.xlf | 5 + src/Installer/dotnetup/xlf/Strings.pt-BR.xlf | 5 + src/Installer/dotnetup/xlf/Strings.ru.xlf | 5 + src/Installer/dotnetup/xlf/Strings.tr.xlf | 5 + .../dotnetup/xlf/Strings.zh-Hans.xlf | 5 + .../dotnetup/xlf/Strings.zh-Hant.xlf | 5 + src/Installer/installer.code-workspace | 6 +- test/dotnetup.Tests/TelemetryTests.cs | 111 +++++++++++++++++- 21 files changed, 406 insertions(+), 12 deletions(-) create mode 100644 src/Installer/dotnetup/Telemetry/FirstRunNotice.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs index ed29b2221ed6..4f6ec5666c34 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs @@ -11,6 +11,45 @@ namespace Microsoft.Dotnet.Installation.Internal; /// This source is listened to by dotnetup's DotnetupTelemetry when running via CLI, /// and can be subscribed to by other consumers via ActivityListener. /// +/// +/// +/// Library consumers can hook into installation telemetry by subscribing to this ActivitySource. +/// The following activities are emitted: +/// +/// +/// downloadSDK/runtime archive download. Tags: download.version, download.url, download.bytes, download.from_cache +/// extractArchive extraction. Tags: download.version +/// +/// +/// Example usage: +/// +/// +/// using var listener = new ActivityListener +/// { +/// ShouldListenTo = source => source.Name == "Microsoft.Dotnet.Installation", +/// Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded, +/// ActivityStarted = activity => +/// { +/// // Add custom tags (e.g., your tool's name) +/// activity.SetTag("caller", "my-custom-tool"); +/// }, +/// ActivityStopped = activity => +/// { +/// // Export to your telemetry system +/// Console.WriteLine($"{activity.DisplayName}: {activity.Duration.TotalMilliseconds}ms"); +/// } +/// }; +/// ActivitySource.AddActivityListener(listener); +/// +/// // Now use the library - activities will be captured +/// var installer = InstallerFactory.Create(progressTarget); +/// installer.Install(root, InstallComponent.Sdk, version); +/// +/// +/// When activities originate from dotnetup CLI, they include the tag caller=dotnetup. +/// Library consumers can use this to distinguish CLI-originated vs direct library calls. +/// +/// internal static class InstallationActivitySource { /// diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index 003087d8faab..e2d12cae6fa9 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -3,6 +3,8 @@ using System.Diagnostics; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +using OpenTelemetry.Trace; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -26,6 +28,8 @@ public IProgressTask AddTask(string description, double maxValue) public IProgressTask AddTask(string activityName, string description, double maxValue) { var activity = InstallationActivitySource.ActivitySource.StartActivity(activityName, ActivityKind.Internal); + // Tag library activities so consumers know they came from dotnetup CLI + activity?.SetTag("caller", "dotnetup"); var task = new ProgressTaskImpl(description, activity) { MaxValue = maxValue }; _tasks.Add(task); AnsiConsole.WriteLine(description + "..."); @@ -75,13 +79,39 @@ public double Value public void RecordError(Exception ex) { if (_activity == null) return; + + // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) + var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); + _activity.SetStatus(ActivityStatusCode.Error, ex.Message); - _activity.SetTag("error.type", ex.GetType().Name); - _activity.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection + _activity.SetTag("error.type", errorInfo.ErrorType); + + if (errorInfo.StatusCode.HasValue) { - { "exception.type", ex.GetType().FullName }, - { "exception.message", ex.Message } - })); + _activity.SetTag("error.http_status", errorInfo.StatusCode.Value); + } + + if (errorInfo.HResult.HasValue) + { + _activity.SetTag("error.hresult", errorInfo.HResult.Value); + } + + if (errorInfo.Details is not null) + { + _activity.SetTag("error.details", errorInfo.Details); + } + + if (errorInfo.SourceLocation is not null) + { + _activity.SetTag("error.source_location", errorInfo.SourceLocation); + } + + if (errorInfo.ExceptionChain is not null) + { + _activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); + } + + _activity.RecordException(ex); } public void Complete() diff --git a/src/Installer/dotnetup/Program.cs b/src/Installer/dotnetup/Program.cs index 4a049e2adb1c..7a0f4cbb03a9 100644 --- a/src/Installer/dotnetup/Program.cs +++ b/src/Installer/dotnetup/Program.cs @@ -14,6 +14,9 @@ public static int Main(string[] args) // This is DEBUG-only and removes the --debug flag from args DotnetupDebugHelper.HandleDebugSwitch(ref args); + // Show first-run telemetry notice if needed + FirstRunNotice.ShowIfFirstRun(DotnetupTelemetry.Instance.Enabled); + // Start root activity for the entire process using var rootActivity = DotnetupTelemetry.Instance.Enabled ? DotnetupTelemetry.CommandSource.StartActivity("dotnetup", ActivityKind.Internal) diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index bc975d139ed3..771e628de34c 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -3,6 +3,8 @@ using System.Diagnostics; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +using OpenTelemetry.Trace; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -39,6 +41,8 @@ public IProgressTask AddTask(string description, double maxValue) public IProgressTask AddTask(string activityName, string description, double maxValue) { var activity = InstallationActivitySource.ActivitySource.StartActivity(activityName, ActivityKind.Internal); + // Tag library activities so consumers know they came from dotnetup CLI + activity?.SetTag("caller", "dotnetup"); var task = new ProgressTaskImpl(_progressContext.AddTask(description, maxValue: maxValue), activity); _tasks.Add(task); return task; @@ -89,13 +93,39 @@ public double MaxValue public void RecordError(Exception ex) { if (_activity == null) return; + + // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) + var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); + _activity.SetStatus(ActivityStatusCode.Error, ex.Message); - _activity.SetTag("error.type", ex.GetType().Name); - _activity.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection + _activity.SetTag("error.type", errorInfo.ErrorType); + + if (errorInfo.StatusCode.HasValue) { - { "exception.type", ex.GetType().FullName }, - { "exception.message", ex.Message } - })); + _activity.SetTag("error.http_status", errorInfo.StatusCode.Value); + } + + if (errorInfo.HResult.HasValue) + { + _activity.SetTag("error.hresult", errorInfo.HResult.Value); + } + + if (errorInfo.Details is not null) + { + _activity.SetTag("error.details", errorInfo.Details); + } + + if (errorInfo.SourceLocation is not null) + { + _activity.SetTag("error.source_location", errorInfo.SourceLocation); + } + + if (errorInfo.ExceptionChain is not null) + { + _activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); + } + + _activity.RecordException(ex); } public void Complete() diff --git a/src/Installer/dotnetup/Strings.resx b/src/Installer/dotnetup/Strings.resx index 3ecaed590083..c6f5b7504f1f 100644 --- a/src/Installer/dotnetup/Strings.resx +++ b/src/Installer/dotnetup/Strings.resx @@ -153,4 +153,7 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + diff --git a/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs new file mode 100644 index 000000000000..f15b318b991d --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Manages the first-run telemetry notice for dotnetup. +/// Displays a brief notice on first use and creates a sentinel file to prevent repeat notices. +/// +internal static class FirstRunNotice +{ + private const string SentinelFileName = ".dotnetup-telemetry-notice"; + private const string TelemetryDocsUrl = "https://aka.ms/dotnetup-telemetry"; + + /// + /// Shows the first-run telemetry notice if this is the first time dotnetup is run + /// and telemetry is enabled. Creates a sentinel file to prevent future notices. + /// + /// Whether telemetry is currently enabled. + public static void ShowIfFirstRun(bool telemetryEnabled) + { + // Don't show notice if telemetry is disabled - user has already opted out + if (!telemetryEnabled) + { + return; + } + + var sentinelPath = GetSentinelPath(); + if (string.IsNullOrEmpty(sentinelPath)) + { + return; + } + + // Check if we've already shown the notice + if (File.Exists(sentinelPath)) + { + return; + } + + // Show the notice + ShowNotice(); + + // Create the sentinel file to prevent future notices + CreateSentinel(sentinelPath); + } + + /// + /// Checks if this is the first run (sentinel doesn't exist). + /// + public static bool IsFirstRun() + { + var sentinelPath = GetSentinelPath(); + return !string.IsNullOrEmpty(sentinelPath) && !File.Exists(sentinelPath); + } + + private static void ShowNotice() + { + // Keep it brief - link to docs for full details + Console.WriteLine(); + Console.WriteLine(Strings.TelemetryNotice); + Console.WriteLine(); + } + + private static string? GetSentinelPath() + { + try + { + // Use the same location as dotnetup's data directory + var baseDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (string.IsNullOrEmpty(baseDir)) + { + return null; + } + + var dotnetupDir = Path.Combine(baseDir, ".dotnetup"); + return Path.Combine(dotnetupDir, SentinelFileName); + } + catch + { + // If we can't determine the path, skip the notice + return null; + } + } + + private static void CreateSentinel(string sentinelPath) + { + try + { + var directory = Path.GetDirectoryName(sentinelPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + // Write version info to the sentinel for debugging purposes + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + File.WriteAllText(sentinelPath, $"dotnetup telemetry notice shown: {DateTime.UtcNow:O}\nVersion: {version}\n"); + } + catch + { + // If we can't create the sentinel, the notice will show again next time + // This is acceptable - better than crashing + } + } +} diff --git a/src/Installer/dotnetup/xlf/Strings.cs.xlf b/src/Installer/dotnetup/xlf/Strings.cs.xlf index a4be8b137cf9..b9a6731266c1 100644 --- a/src/Installer/dotnetup/xlf/Strings.cs.xlf +++ b/src/Installer/dotnetup/xlf/Strings.cs.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.de.xlf b/src/Installer/dotnetup/xlf/Strings.de.xlf index 7c6450cba7e5..4d188ff3da2d 100644 --- a/src/Installer/dotnetup/xlf/Strings.de.xlf +++ b/src/Installer/dotnetup/xlf/Strings.de.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.es.xlf b/src/Installer/dotnetup/xlf/Strings.es.xlf index 841161ac0a54..a97f4d4c5d05 100644 --- a/src/Installer/dotnetup/xlf/Strings.es.xlf +++ b/src/Installer/dotnetup/xlf/Strings.es.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.fr.xlf b/src/Installer/dotnetup/xlf/Strings.fr.xlf index 8c07d8f03c27..603607471508 100644 --- a/src/Installer/dotnetup/xlf/Strings.fr.xlf +++ b/src/Installer/dotnetup/xlf/Strings.fr.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.it.xlf b/src/Installer/dotnetup/xlf/Strings.it.xlf index ee25a38b22f2..096ffbb992ac 100644 --- a/src/Installer/dotnetup/xlf/Strings.it.xlf +++ b/src/Installer/dotnetup/xlf/Strings.it.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.ja.xlf b/src/Installer/dotnetup/xlf/Strings.ja.xlf index a830862e3bb9..51df857e94f8 100644 --- a/src/Installer/dotnetup/xlf/Strings.ja.xlf +++ b/src/Installer/dotnetup/xlf/Strings.ja.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.ko.xlf b/src/Installer/dotnetup/xlf/Strings.ko.xlf index 113f6a8f241a..7420618ba4be 100644 --- a/src/Installer/dotnetup/xlf/Strings.ko.xlf +++ b/src/Installer/dotnetup/xlf/Strings.ko.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.pl.xlf b/src/Installer/dotnetup/xlf/Strings.pl.xlf index 4992bedfc309..8bcd28855034 100644 --- a/src/Installer/dotnetup/xlf/Strings.pl.xlf +++ b/src/Installer/dotnetup/xlf/Strings.pl.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.pt-BR.xlf b/src/Installer/dotnetup/xlf/Strings.pt-BR.xlf index 323c521ee8d8..73bde98a9434 100644 --- a/src/Installer/dotnetup/xlf/Strings.pt-BR.xlf +++ b/src/Installer/dotnetup/xlf/Strings.pt-BR.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.ru.xlf b/src/Installer/dotnetup/xlf/Strings.ru.xlf index 220be87ddd84..1a1e4e49f5a5 100644 --- a/src/Installer/dotnetup/xlf/Strings.ru.xlf +++ b/src/Installer/dotnetup/xlf/Strings.ru.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.tr.xlf b/src/Installer/dotnetup/xlf/Strings.tr.xlf index 54e22b070ffa..6f2a5f16b430 100644 --- a/src/Installer/dotnetup/xlf/Strings.tr.xlf +++ b/src/Installer/dotnetup/xlf/Strings.tr.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.zh-Hans.xlf b/src/Installer/dotnetup/xlf/Strings.zh-Hans.xlf index bf7a861aded0..08205ebb2612 100644 --- a/src/Installer/dotnetup/xlf/Strings.zh-Hans.xlf +++ b/src/Installer/dotnetup/xlf/Strings.zh-Hans.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/dotnetup/xlf/Strings.zh-Hant.xlf b/src/Installer/dotnetup/xlf/Strings.zh-Hant.xlf index e6661c06b0f5..e709eb424399 100644 --- a/src/Installer/dotnetup/xlf/Strings.zh-Hant.xlf +++ b/src/Installer/dotnetup/xlf/Strings.zh-Hant.xlf @@ -62,6 +62,11 @@ .NET installation manager for user level installs. + + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + dotnetup collects usage data to help improve your experience. You can opt out by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. Learn more: https://aka.ms/dotnetup-telemetry + + \ No newline at end of file diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index baba8b6e6742..1ecbdccc0bd4 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -34,6 +34,10 @@ "stopAtEntry": false, "logging": { "moduleLoad": false + }, + "env": { + "DOTNETUP_DEV_BUILD": "1", + "DOTNET_CLI_TELEMETRY_OPTOUT": "0" } }, { @@ -163,4 +167,4 @@ }, ] } -} +} \ No newline at end of file diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 07d6607f0a9f..d1a87fdb2a1f 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.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.Runtime.InteropServices; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Xunit; @@ -17,7 +18,7 @@ public void Hash_SameInput_ProducesSameOutput() Assert.Equal(hash1, hash2); } - + [Fact] public void Hash_DifferentInputs_ProduceDifferentOutputs() { @@ -267,3 +268,111 @@ public void RecordException_WithNullActivity_DoesNotThrow() Assert.Null(exception); } } + +public class LibraryActivityTagTests +{ + [Fact] + public void NonUpdatingProgressTarget_SetsCallerTagOnActivity() + { + var capturedActivities = new List(); + + using var listener = new System.Diagnostics.ActivityListener + { + ShouldListenTo = source => source.Name == "Microsoft.Dotnet.Installation", + Sample = (ref System.Diagnostics.ActivityCreationOptions _) => + System.Diagnostics.ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => capturedActivities.Add(activity) + }; + System.Diagnostics.ActivitySource.AddActivityListener(listener); + + // Capture console output to avoid test noise + var originalOut = Console.Out; + Console.SetOut(TextWriter.Null); + try + { + // Use the progress target to create an activity + var progressTarget = new NonUpdatingProgressTarget(); + using var reporter = progressTarget.CreateProgressReporter(); + var task = reporter.AddTask("test-activity", "Test Description", 100); + task.Value = 100; + // Disposing the reporter will stop/dispose the activities + } + finally + { + Console.SetOut(originalOut); + } + + // Verify the activity was captured and has the caller tag + Assert.Single(capturedActivities); + var activity = capturedActivities[0]; + Assert.Equal("dotnetup", activity.GetTagItem("caller")?.ToString()); + } +} + +public class FirstRunNoticeTests +{ + [Fact] + public void IsFirstRun_ReturnsTrueWhenSentinelDoesNotExist() + { + // Clean up any existing sentinel for this test + var sentinelDir = GetSentinelDirectory(); + var sentinelPath = Path.Combine(sentinelDir, ".dotnetup-telemetry-notice"); + + if (File.Exists(sentinelPath)) + { + File.Delete(sentinelPath); + } + + Assert.True(FirstRunNotice.IsFirstRun()); + } + + [Fact] + public void ShowIfFirstRun_CreatesSentinelFile() + { + // Clean up any existing sentinel for this test + var sentinelDir = GetSentinelDirectory(); + var sentinelPath = Path.Combine(sentinelDir, ".dotnetup-telemetry-notice"); + + if (File.Exists(sentinelPath)) + { + File.Delete(sentinelPath); + } + + // Simulate first run with telemetry enabled + FirstRunNotice.ShowIfFirstRun(telemetryEnabled: true); + + // Sentinel should now exist + Assert.True(File.Exists(sentinelPath)); + + // Subsequent calls should not be "first run" + Assert.False(FirstRunNotice.IsFirstRun()); + } + + [Fact] + public void ShowIfFirstRun_DoesNotCreateSentinel_WhenTelemetryDisabled() + { + // Clean up any existing sentinel for this test + var sentinelDir = GetSentinelDirectory(); + var sentinelPath = Path.Combine(sentinelDir, ".dotnetup-telemetry-notice"); + + if (File.Exists(sentinelPath)) + { + File.Delete(sentinelPath); + } + + // Simulate first run with telemetry disabled + FirstRunNotice.ShowIfFirstRun(telemetryEnabled: false); + + // Sentinel should NOT be created (user has opted out) + Assert.False(File.Exists(sentinelPath)); + } + + private static string GetSentinelDirectory() + { + var baseDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + return Path.Combine(baseDir, ".dotnetup"); + } +} From dc49cd3ea8f9d1dde80f908a6952c43b2396d596 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 15:46:17 -0800 Subject: [PATCH 12/59] base implementation - user error/uncontrolled failure vs our product is wrong failures some of the categories may be incorrect, but this is a good starting point --- .../Internal/InstallationActivitySource.cs | 2 +- .../dotnetup/NonUpdatingProgressTarget.cs | 6 +- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 8 +- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 131 +++++++++++++++- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 141 ++++++++++++++++++ test/dotnetup.Tests/TelemetryTests.cs | 2 +- 6 files changed, 283 insertions(+), 7 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs index 4f6ec5666c34..2e2615ef4f54 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs @@ -40,7 +40,7 @@ namespace Microsoft.Dotnet.Installation.Internal; /// } /// }; /// ActivitySource.AddActivityListener(listener); -/// +/// /// // Now use the library - activities will be captured /// var installer = InstallerFactory.Create(progressTarget); /// installer.Install(root, InstallComponent.Sdk, version); diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index e2d12cae6fa9..c4b643ab1729 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -79,13 +79,13 @@ public double Value public void RecordError(Exception ex) { if (_activity == null) return; - + // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - + _activity.SetStatus(ActivityStatusCode.Error, ex.Message); _activity.SetTag("error.type", errorInfo.ErrorType); - + if (errorInfo.StatusCode.HasValue) { _activity.SetTag("error.http_status", errorInfo.StatusCode.Value); diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index b348abd1d840..55633089b3fc 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -149,6 +149,7 @@ public void RecordException(Activity? activity, Exception ex, string? errorCode activity.SetStatus(ActivityStatusCode.Error, ex.Message); activity.SetTag("error.type", errorInfo.ErrorType); activity.SetTag("error.code", errorCode ?? errorInfo.ErrorType); + activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); if (errorInfo.StatusCode.HasValue) { @@ -200,7 +201,12 @@ public void PostEvent( return; } - activity.SetTag("session.id", _sessionId); + // Add common properties to each span for App Insights customDimensions + foreach (var attr in TelemetryCommonProperties.GetCommonAttributes(_sessionId)) + { + activity.SetTag(attr.Key, attr.Value?.ToString()); + } + activity.SetTag("caller", "dotnetup"); if (properties != null) { diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 9976c272b7d0..109a8d4e789d 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -9,10 +9,30 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +/// +/// Categorizes errors for telemetry purposes. +/// +public enum ErrorCategory +{ + /// + /// Product errors - issues we can take action on (bugs, crashes, server issues). + /// These count against product quality metrics. + /// + Product, + + /// + /// User errors - issues caused by user input or environment we can't control. + /// Examples: invalid version, disk full, network timeout, permission denied. + /// These are tracked separately and don't count against success rate. + /// + User +} + /// /// Error info extracted from an exception for telemetry. /// /// The error type/code for telemetry. +/// Whether this is a product or user error. /// HTTP status code if applicable. /// Win32 HResult if applicable. /// Additional context (no PII - sanitized values only). @@ -20,6 +40,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// Chain of exception types for wrapped exceptions. public sealed record ExceptionErrorInfo( string ErrorType, + ErrorCategory Category = ErrorCategory.Product, int? StatusCode = null, int? HResult = null, string? Details = null, @@ -56,76 +77,149 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) return ex switch { - // DotnetInstallException has specific error codes + // DotnetInstallException has specific error codes - categorize by error code DotnetInstallException installEx => new ExceptionErrorInfo( installEx.ErrorCode.ToString(), + Category: GetInstallErrorCategory(installEx.ErrorCode), Details: installEx.Version, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), + // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues HttpRequestException httpEx => new ExceptionErrorInfo( httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", + Category: GetHttpErrorCategory(httpEx.StatusCode), StatusCode: (int?)httpEx.StatusCode, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), // FileNotFoundException before IOException (it derives from IOException) + // Could be user error (wrong path) or product error (our code referenced wrong file) + // Default to product since we should handle missing files gracefully FileNotFoundException fnfEx => new ExceptionErrorInfo( "FileNotFound", + Category: ErrorCategory.Product, HResult: fnfEx.HResult, Details: fnfEx.FileName is not null ? "file_specified" : null, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), + // Permission denied - user environment issue (needs elevation or different permissions) UnauthorizedAccessException => new ExceptionErrorInfo( "PermissionDenied", + Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), + // Directory not found - could be user specified bad path DirectoryNotFoundException => new ExceptionErrorInfo( "DirectoryNotFound", + Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain), + // User cancelled the operation OperationCanceledException => new ExceptionErrorInfo( "Cancelled", + Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), + // Invalid argument - user provided bad input ArgumentException argEx => new ExceptionErrorInfo( "InvalidArgument", + Category: ErrorCategory.User, Details: argEx.ParamName, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), + // Invalid operation - usually a bug in our code InvalidOperationException => new ExceptionErrorInfo( "InvalidOperation", + Category: ErrorCategory.Product, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), + // Not supported - could be user trying unsupported scenario NotSupportedException => new ExceptionErrorInfo( "NotSupported", + Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), + // Timeout - network/environment issue outside our control TimeoutException => new ExceptionErrorInfo( "Timeout", + Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), + // Unknown exceptions default to product (fail-safe - we should handle known cases) _ => new ExceptionErrorInfo( ex.GetType().Name, + Category: ErrorCategory.Product, SourceLocation: sourceLocation, ExceptionChain: exceptionChain) }; } + /// + /// Gets the error category for a DotnetInstallErrorCode. + /// + private static ErrorCategory GetInstallErrorCategory(DotnetInstallErrorCode errorCode) + { + return errorCode switch + { + // User errors - bad input or environment issues + DotnetInstallErrorCode.VersionNotFound => ErrorCategory.User, // User typed invalid version + DotnetInstallErrorCode.ReleaseNotFound => ErrorCategory.User, // User requested non-existent release + DotnetInstallErrorCode.InvalidChannel => ErrorCategory.User, // User provided bad channel format + DotnetInstallErrorCode.PermissionDenied => ErrorCategory.User, // User needs to elevate/fix permissions + DotnetInstallErrorCode.DiskFull => ErrorCategory.User, // User's disk is full + DotnetInstallErrorCode.NetworkError => ErrorCategory.User, // User's network issue + + // Product errors - issues we can take action on + DotnetInstallErrorCode.NoMatchingFile => ErrorCategory.Product, // Our manifest/logic issue + DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue + DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue + DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue + DotnetInstallErrorCode.Unknown => ErrorCategory.Product, // Unknown = assume product issue + + _ => ErrorCategory.Product // Default to product for new codes + }; + } + + /// + /// Gets the error category for an HTTP status code. + /// + private static ErrorCategory GetHttpErrorCategory(HttpStatusCode? statusCode) + { + if (!statusCode.HasValue) + { + // No status code usually means network failure - user environment + return ErrorCategory.User; + } + + var code = (int)statusCode.Value; + return code switch + { + >= 500 => ErrorCategory.Product, // 5xx server errors - our infrastructure + 404 => ErrorCategory.User, // Not found - likely user requested invalid resource + 403 => ErrorCategory.User, // Forbidden - user environment/permission issue + 401 => ErrorCategory.User, // Unauthorized - user auth issue + 408 => ErrorCategory.User, // Request timeout - user network + 429 => ErrorCategory.User, // Too many requests - user hitting rate limits + _ => ErrorCategory.Product // Other 4xx - likely our bug (bad request format, etc.) + }; + } + private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain) { string errorType; string? details; + ErrorCategory category; // On Windows, use Win32Exception to get the readable error message if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && ioEx.HResult != 0) @@ -137,21 +231,56 @@ private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourc // Derive a short error type from the HResult errorType = GetWindowsErrorType(ioEx.HResult); + category = GetIOErrorCategory(errorType); } else { // On non-Windows or if no HResult, use our mapping (errorType, details) = GetErrorTypeFromHResult(ioEx.HResult); + category = GetIOErrorCategory(errorType); } return new ExceptionErrorInfo( errorType, + Category: category, HResult: ioEx.HResult, Details: details, SourceLocation: sourceLocation, ExceptionChain: exceptionChain); } + /// + /// Gets the error category for an IO error type. + /// + private static ErrorCategory GetIOErrorCategory(string errorType) + { + return errorType switch + { + // User environment issues - we can't control these + "DiskFull" => ErrorCategory.User, + "PermissionDenied" => ErrorCategory.User, + "SharingViolation" => ErrorCategory.User, // File in use by another process + "LockViolation" => ErrorCategory.User, // File locked + "PathTooLong" => ErrorCategory.User, // User's path is too long + "InvalidPath" => ErrorCategory.User, // User specified invalid path + "PathNotFound" => ErrorCategory.User, // User's directory doesn't exist + "NetworkPathNotFound" => ErrorCategory.User, // Network issue + "NetworkNameDeleted" => ErrorCategory.User, // Network issue + "DeviceFailure" => ErrorCategory.User, // Hardware issue + "SemaphoreTimeout" => ErrorCategory.User, // System resource contention + "AlreadyExists" => ErrorCategory.User, // User already has file + "FileExists" => ErrorCategory.User, // User already has file + + // Product issues - we should handle these better + "FileNotFound" => ErrorCategory.Product, // Our code referenced missing file + "GeneralFailure" => ErrorCategory.Product, // Unknown IO error + "InvalidParameter" => ErrorCategory.Product, // Our code passed bad params + "IOException" => ErrorCategory.Product, // Generic IO - assume product + + _ => ErrorCategory.Product // Unknown - assume product + }; + } + /// /// Gets a short error type name from a Windows HResult. /// diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index 7ff7f6a6543d..8ac8404d834b 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; +using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Xunit; @@ -276,4 +277,144 @@ public void GetErrorInfo_AlreadyExists_MapsCorrectly() Assert.Equal("ERROR_ALREADY_EXISTS", info.Details); } } + + // Error category tests + [Theory] + [InlineData(DotnetInstallErrorCode.VersionNotFound, ErrorCategory.User)] + [InlineData(DotnetInstallErrorCode.ReleaseNotFound, ErrorCategory.User)] + [InlineData(DotnetInstallErrorCode.InvalidChannel, ErrorCategory.User)] + [InlineData(DotnetInstallErrorCode.PermissionDenied, ErrorCategory.User)] + [InlineData(DotnetInstallErrorCode.DiskFull, ErrorCategory.User)] + [InlineData(DotnetInstallErrorCode.NetworkError, ErrorCategory.User)] + [InlineData(DotnetInstallErrorCode.NoMatchingFile, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.DownloadFailed, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.HashMismatch, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.ExtractionFailed, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.Unknown, ErrorCategory.Product)] + public void GetErrorInfo_DotnetInstallException_HasCorrectCategory(DotnetInstallErrorCode errorCode, ErrorCategory expectedCategory) + { + var ex = new DotnetInstallException(errorCode, "Test message"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(expectedCategory, info.Category); + } + + [Fact] + public void GetErrorInfo_UnauthorizedAccessException_IsUserError() + { + var ex = new UnauthorizedAccessException("Access denied"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + [Fact] + public void GetErrorInfo_TimeoutException_IsUserError() + { + var ex = new TimeoutException("Operation timed out"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + [Fact] + public void GetErrorInfo_ArgumentException_IsUserError() + { + var ex = new ArgumentException("Invalid argument", "testParam"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + [Fact] + public void GetErrorInfo_OperationCanceledException_IsUserError() + { + var ex = new OperationCanceledException("Cancelled by user"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + [Fact] + public void GetErrorInfo_InvalidOperationException_IsProductError() + { + var ex = new InvalidOperationException("Invalid state"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.Product, info.Category); + } + + [Fact] + public void GetErrorInfo_UnknownException_DefaultsToProductError() + { + var ex = new CustomTestException("Test"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.Product, info.Category); + } + + [Fact] + public void GetErrorInfo_IOException_DiskFull_IsUserError() + { + // HResult for ERROR_DISK_FULL (0x80070070 = -2147024784) + var ex = new IOException("Disk full", unchecked((int)0x80070070)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + [Fact] + public void GetErrorInfo_IOException_SharingViolation_IsUserError() + { + // HResult for ERROR_SHARING_VIOLATION (0x80070020 = -2147024864) + var ex = new IOException("File in use", unchecked((int)0x80070020)); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + [Fact] + public void GetErrorInfo_HttpRequestException_5xx_IsProductError() + { + var ex = new HttpRequestException("Server error", null, System.Net.HttpStatusCode.InternalServerError); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.Product, info.Category); + } + + [Fact] + public void GetErrorInfo_HttpRequestException_404_IsUserError() + { + var ex = new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + [Fact] + public void GetErrorInfo_HttpRequestException_NoStatusCode_IsUserError() + { + // No status code typically means network connectivity issue + var ex = new HttpRequestException("Network error"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + private class CustomTestException : Exception + { + public CustomTestException(string message) : base(message) { } + } } diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index d1a87fdb2a1f..b006a1a761a7 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -18,7 +18,7 @@ public void Hash_SameInput_ProducesSameOutput() Assert.Equal(hash1, hash2); } - + [Fact] public void Hash_DifferentInputs_ProduceDifferentOutputs() { From 28d30a1dfe1d5d28d9350a26eca01bd238406a59 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 15:59:51 -0800 Subject: [PATCH 13/59] consider more failures product failures --- .../DotnetInstallException.cs | 12 ++++++++++++ .../dotnetup/Telemetry/ErrorCodeMapper.cs | 14 +++++++------- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 5 +++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs index 54188bb35a26..791d30e368a7 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs @@ -40,6 +40,18 @@ public enum DotnetInstallErrorCode /// Disk space issue. DiskFull, + + /// Failed to fetch the releases manifest from Microsoft servers. + ManifestFetchFailed, + + /// Failed to parse the releases manifest (invalid JSON or schema). + ManifestParseFailed, + + /// The archive file is corrupted or truncated. + ArchiveCorrupted, + + /// Another installation process is already running. + InstallationLocked, } /// diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 109a8d4e789d..2e8130c50d65 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -259,19 +259,19 @@ private static ErrorCategory GetIOErrorCategory(string errorType) // User environment issues - we can't control these "DiskFull" => ErrorCategory.User, "PermissionDenied" => ErrorCategory.User, - "SharingViolation" => ErrorCategory.User, // File in use by another process - "LockViolation" => ErrorCategory.User, // File locked - "PathTooLong" => ErrorCategory.User, // User's path is too long "InvalidPath" => ErrorCategory.User, // User specified invalid path "PathNotFound" => ErrorCategory.User, // User's directory doesn't exist "NetworkPathNotFound" => ErrorCategory.User, // Network issue "NetworkNameDeleted" => ErrorCategory.User, // Network issue "DeviceFailure" => ErrorCategory.User, // Hardware issue - "SemaphoreTimeout" => ErrorCategory.User, // System resource contention - "AlreadyExists" => ErrorCategory.User, // User already has file - "FileExists" => ErrorCategory.User, // User already has file - // Product issues - we should handle these better + // Product issues - we should handle these gracefully + "SharingViolation" => ErrorCategory.Product, // Could be our mutex/lock issue + "LockViolation" => ErrorCategory.Product, // Could be our mutex/lock issue + "PathTooLong" => ErrorCategory.Product, // We control the install path + "SemaphoreTimeout" => ErrorCategory.Product, // Could be our concurrency issue + "AlreadyExists" => ErrorCategory.Product, // We should handle existing files gracefully + "FileExists" => ErrorCategory.Product, // We should handle existing files gracefully "FileNotFound" => ErrorCategory.Product, // Our code referenced missing file "GeneralFailure" => ErrorCategory.Product, // Unknown IO error "InvalidParameter" => ErrorCategory.Product, // Our code passed bad params diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index 8ac8404d834b..af04831b43d8 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -372,14 +372,15 @@ public void GetErrorInfo_IOException_DiskFull_IsUserError() } [Fact] - public void GetErrorInfo_IOException_SharingViolation_IsUserError() + public void GetErrorInfo_IOException_SharingViolation_IsProductError() { // HResult for ERROR_SHARING_VIOLATION (0x80070020 = -2147024864) + // Could be our mutex/lock issue var ex = new IOException("File in use", unchecked((int)0x80070020)); var info = ErrorCodeMapper.GetErrorInfo(ex); - Assert.Equal(ErrorCategory.User, info.Category); + Assert.Equal(ErrorCategory.Product, info.Category); } [Fact] From ff0c333bce8d8417bd792ef406aaa06e22fccca2 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 16:00:03 -0800 Subject: [PATCH 14/59] initial telemetry dashboard --- .../dotnetup/Telemetry/dotnetup-workbook.json | 648 ++++++++++++++++++ 1 file changed, 648 insertions(+) create mode 100644 src/Installer/dotnetup/Telemetry/dotnetup-workbook.json diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json new file mode 100644 index 000000000000..9da69520b9a0 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -0,0 +1,648 @@ +{ + "version": "Notebook/1.0", + "items": [ + { + "type": 1, + "content": { + "json": "# dotnetup Telemetry Dashboard\n\nMonitoring installation success rates, usage patterns, and error analysis." + }, + "name": "header" + }, + { + "type": 9, + "content": { + "version": "KqlParameterItem/1.0", + "parameters": [ + { + "id": "time-range", + "version": "KqlParameterItem/1.0", + "name": "TimeRange", + "type": 4, + "isRequired": true, + "value": { + "durationMs": 604800000 + }, + "typeSettings": { + "selectableValues": [ + { + "durationMs": 3600000, + "displayName": "Last hour" + }, + { + "durationMs": 86400000, + "displayName": "Last 24 hours" + }, + { + "durationMs": 604800000, + "displayName": "Last 7 days" + }, + { + "durationMs": 2592000000, + "displayName": "Last 30 days" + } + ] + }, + "label": "Time Range" + }, + { + "id": "version-filter", + "version": "KqlParameterItem/1.0", + "name": "VersionFilter", + "type": 2, + "isRequired": true, + "query": "dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| where isnotempty(version)\n| distinct version\n| order by version desc\n| take 20\n| union (datatable(version:string)[\"all\"])\n| order by version asc", + "value": "all", + "typeSettings": { + "additionalResourceOptions": [], + "showDefault": false + }, + "queryType": 0, + "resourceType": "microsoft.insights/components", + "label": "Version" + }, + { + "id": "dev-build-filter", + "version": "KqlParameterItem/1.0", + "name": "DevBuildFilter", + "type": 2, + "isRequired": true, + "value": "all", + "typeSettings": { + "additionalResourceOptions": [], + "showDefault": false + }, + "jsonData": "[{\"value\": \"all\", \"label\": \"All (dev + prod)\"}, {\"value\": \"exclude\", \"label\": \"Production only\"}, {\"value\": \"only\", \"label\": \"Dev builds only\"}]", + "label": "Dev Builds" + } + ], + "style": "pills" + }, + "name": "parameters" + }, + { + "type": 1, + "content": { + "json": "## Success Rate Overview\n\n*Success rate excludes user errors (invalid input, permissions, disk full, network issues). Only product issues count against quality.*" + }, + "name": "success-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\nlet filteredData = dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter);\nlet versions = filteredData\n| where isnotempty(version)\n| distinct version\n| order by version desc\n| take 2\n| extend rank = row_number();\nlet latestVersion = toscalar(versions | where rank == 1 | project version);\nlet priorVersion = toscalar(versions | where rank == 2 | project version);\nlet latestStats = filteredData\n| where version == latestVersion\n| summarize \n Total = count(), \n Successful = countif(success == true), \n ProductErrors = countif(success == false and error_category == \"product\"),\n UserErrors = countif(success == false and error_category == \"user\")\n| extend \n Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1), \n Version = latestVersion, \n Label = \"Latest\";\nlet priorStats = filteredData\n| where version == priorVersion and isnotempty(priorVersion)\n| summarize \n Total = count(), \n Successful = countif(success == true), \n ProductErrors = countif(success == false and error_category == \"product\"),\n UserErrors = countif(success == false and error_category == \"user\")\n| extend \n Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1), \n Version = priorVersion, \n Label = \"Prior\";\nlatestStats\n| union priorStats\n| where isnotempty(Version)\n| project Label, Version, ['Success Rate'] = strcat(SuccessRate, '%'), Relevant, Successful, ['Product Errors'] = ProductErrors, ['User Errors'] = UserErrors\n| order by Label asc", + "size": 1, + "title": "Product Success Rate by Version", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Success Rate", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "colors", + "thresholdsGrid": [ + { + "operator": ">=", + "thresholdValue": "95", + "representation": "green", + "text": "{0}" + }, + { + "operator": ">=", + "thresholdValue": "80", + "representation": "yellow", + "text": "{0}" + }, + { + "operator": "Default", + "representation": "red", + "text": "{0}" + } + ] + } + }, + { + "columnMatch": "Successful", + "formatter": 8, + "formatOptions": { + "palette": "green" + } + }, + { + "columnMatch": "Product Errors", + "formatter": 8, + "formatOptions": { + "palette": "red" + } + }, + { + "columnMatch": "User Errors", + "formatter": 8, + "formatOptions": { + "palette": "yellow" + } + } + ], + "rowLimit": 2 + } + }, + "customWidth": "30", + "name": "success-tiles" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n version = coalesce(tostring(customDimensions[\"dotnetup.version\"]), \"unknown\"),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(command)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize \n Total = count(),\n Successful = countif(success == true),\n ProductErrors = countif(success == false and error_category == \"product\"),\n UserErrors = countif(success == false and error_category == \"user\")\n by command, version\n| where version != \"unknown\" or Total >= 25\n| extend Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1)\n| order by command asc, version desc\n| project Command = command, Version = version, ['Success Rate'] = SuccessRate, Relevant, Successful, ['Product Errors'] = ProductErrors, ['User Errors'] = UserErrors", + "size": 1, + "title": "Product Success Rate by Command & Version", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Success Rate", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "colors", + "thresholdsGrid": [ + { + "operator": ">=", + "thresholdValue": "95", + "representation": "green", + "text": "{0}%" + }, + { + "operator": ">=", + "thresholdValue": "80", + "representation": "yellow", + "text": "{0}%" + }, + { + "operator": "Default", + "representation": "red", + "text": "{0}%" + } + ] + } + } + ], + "rowLimit": 50 + } + }, + "customWidth": "40", + "name": "success-by-command-version" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\nlet baseData = dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"]),\n command = tostring(customDimensions[\"command.name\"]),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true);\nlet latestVersion = toscalar(baseData | where isnotempty(version) | summarize arg_max(timestamp, version) | project version);\nlet targetVersion = iif(versionFilter == 'all', latestVersion, versionFilter);\nbaseData\n| where version == targetVersion\n| where isnotempty(command)\n| summarize \n Total = count(),\n Successful = countif(success == true),\n UserErrors = countif(success == false and error_category == \"user\")\n by bin(timestamp, 1d), command\n| extend SuccessRate = 100.0 * Successful / (Total - UserErrors)\n| project timestamp, command, SuccessRate\n| render timechart", + "size": 1, + "title": "Product Success Rate Over Time (by Command)", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "timechart", + "chartSettings": { + "ySettings": { + "min": 0, + "max": 100 + } + } + }, + "customWidth": "35", + "name": "success-timechart" + }, + { + "type": 1, + "content": { + "json": "## Command Usage" + }, + "name": "usage-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| count", + "size": 4, + "title": "Total Commands", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "card", + "textSettings": { + "style": "bignumber" + } + }, + "customWidth": "20", + "name": "total-commands" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"]), version = tostring(customDimensions[\"dotnetup.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(command)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by command\n| order by Count desc\n| render piechart", + "size": 1, + "title": "Commands by Type", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "40", + "name": "commands-piechart" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"]), version = tostring(customDimensions[\"dotnetup.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(command)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by bin(timestamp, 1d), command\n| render timechart", + "size": 1, + "title": "Command Usage Over Time", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "timechart" + }, + "customWidth": "40", + "name": "commands-timechart" + }, + { + "type": 1, + "content": { + "json": "## Platform & Environment" + }, + "name": "platform-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend os = tostring(customDimensions[\"os.platform\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(os)\n| summarize Count = count() by os\n| render piechart", + "size": 1, + "title": "Usage by OS", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "25", + "name": "os-piechart" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend arch = tostring(customDimensions[\"process.arch\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(arch)\n| summarize Count = count() by arch\n| render piechart", + "size": 1, + "title": "Usage by Architecture", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "25", + "name": "arch-piechart" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend ci = tostring(customDimensions[\"ci.detected\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(ci)\n| summarize Count = count() by CI_Environment = iif(ci == \"true\" or ci == \"True\", \"CI/CD\", \"Interactive\")\n| render piechart", + "size": 1, + "title": "CI vs Interactive", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "25", + "name": "ci-piechart" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend os = tostring(customDimensions[\"os.platform\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(os)\n| summarize \n Total = count(),\n SuccessRate = round(100.0 * countif(success == true) / count(), 1)\n by os\n| order by Total desc\n| project OS = os, Total, ['Success Rate'] = strcat(SuccessRate, '%')", + "size": 1, + "title": "Success Rate by OS", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Success Rate", + "formatter": 1 + } + ] + } + }, + "customWidth": "25", + "name": "success-by-os" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\" or name == \"download\" or name == \"extract\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend caller = coalesce(tostring(customDimensions[\"caller\"]), \"(library direct)\")\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| summarize Count = count() by caller\n| render piechart", + "size": 1, + "title": "Usage by Caller", + "noDataMessage": "No telemetry with caller tag yet.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "25", + "name": "caller-piechart" + }, + { + "type": 1, + "content": { + "json": "## SDK Installations" + }, + "name": "install-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"download\" or name == \"extract\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"download.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(version)\n| summarize Count = count() by version\n| order by Count desc\n| take 20\n| render barchart", + "size": 1, + "title": "Most Installed SDK Versions", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "barchart" + }, + "customWidth": "50", + "name": "installed-versions" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"command/sdk/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend requested = tostring(customDimensions[\"sdk.requested\"]),\n source = tostring(customDimensions[\"sdk.request_source\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| extend display_requested = case(\n source == \"default-latest\", \"(default: latest)\",\n source == \"default-globaljson\", strcat(\"(global.json: \", requested, \")\"),\n source == \"explicit\" and isnotempty(requested), requested,\n isnotempty(requested), requested,\n isnotempty(source), strcat(\"(\", source, \")\"),\n \"(no request data)\")\n| summarize Count = count() by display_requested\n| order by Count desc\n| take 15\n| render barchart", + "size": 1, + "title": "Requested SDK Versions (User Input)", + "noDataMessage": "No SDK install telemetry with request source data yet. This data is populated when users run 'dotnetup sdk install'.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "barchart" + }, + "customWidth": "50", + "name": "requested-versions" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"command/sdk/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend source = coalesce(tostring(customDimensions[\"sdk.request_source\"]), \"unknown\")\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by source\n| render piechart", + "size": 1, + "title": "Request Source Distribution", + "noDataMessage": "No SDK install telemetry data yet.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "30", + "name": "request-source-distribution" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"download\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend from_cache = tostring(customDimensions[\"download.from_cache\"]),\n bytes = todouble(customDimensions[\"download.bytes\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| summarize \n TotalDownloads = count(),\n CacheHits = countif(from_cache == \"true\" or from_cache == \"True\"),\n AvgSizeMB = round(avg(bytes) / 1048576, 2)\n| extend CacheHitRate = round(100.0 * CacheHits / TotalDownloads, 1)\n| project TotalDownloads, CacheHits, ['Cache Hit Rate'] = strcat(CacheHitRate, '%'), ['Avg Size MB'] = AvgSizeMB", + "size": 1, + "title": "Download Statistics", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "customWidth": "35", + "name": "download-stats" + }, + { + "type": 1, + "content": { + "json": "## Product Errors (Quality Issues)\n\n*These are errors we can take action on - bugs, crashes, server issues.*" + }, + "name": "errors-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| extend error_type = coalesce(tostring(customDimensions[\"error.type\"]), \"Unknown\")\n| extend error_code = tostring(customDimensions[\"error.code\"])\n| extend display_error = iif(error_type == \"Exception\" and isnotempty(error_code) and error_code != \"Exception\", error_code, error_type)\n| where display_error != \"Unknown\"\n| summarize Count = count() by display_error\n| order by Count desc\n| render barchart", + "size": 1, + "title": "Product Errors by Type", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "barchart" + }, + "customWidth": "50", + "name": "errors-by-type" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend error_type = tostring(customDimensions[\"error.type\"]),\n http_status = tostring(customDimensions[\"error.http_status\"]),\n command = tostring(customDimensions[\"command.name\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(http_status)\n| summarize Count = count() by http_status, error_type\n| order by Count desc\n| project ['HTTP Status'] = http_status, ['Error Type'] = error_type, Count", + "size": 1, + "title": "HTTP Errors (Product)", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "customWidth": "50", + "name": "http-errors" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n source_location = tostring(customDimensions[\"error.source_location\"]),\n exception_chain = tostring(customDimensions[\"error.exception_chain\"]),\n hresult = tostring(customDimensions[\"error.hresult\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, error_details, source_location, exception_chain, hresult, os, version\n| order by timestamp desc\n| take 25", + "size": 1, + "title": "Recent Product Failures (Detailed)", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "name": "recent-failures" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend source_location = tostring(customDimensions[\"error.source_location\"]),\n error_type = tostring(customDimensions[\"error.type\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(source_location)\n| summarize Count = count() by source_location, error_type\n| order by Count desc\n| take 20\n| project ['Source Location'] = source_location, ['Error Type'] = error_type, Count", + "size": 1, + "title": "Product Errors by Source Location", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "customWidth": "50", + "name": "errors-by-source-location" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend exception_chain = tostring(customDimensions[\"error.exception_chain\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(exception_chain)\n| summarize Count = count() by exception_chain\n| order by Count desc\n| take 15\n| project ['Exception Chain'] = exception_chain, Count", + "size": 1, + "title": "Common Exception Chains (Product)", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "customWidth": "50", + "name": "exception-chains" + }, + { + "type": 1, + "content": { + "json": "## User Errors (UX Opportunities)\n\n*These indicate user confusion or environmental issues - opportunities to improve documentation, validation, or error messages.*" + }, + "name": "user-errors-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where error_category == \"user\"\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| extend error_type = coalesce(tostring(customDimensions[\"error.type\"]), \"Unknown\")\n| summarize Count = count() by error_type\n| order by Count desc\n| render piechart", + "size": 1, + "title": "User Errors by Type", + "noDataMessage": "No user errors recorded yet. User errors include: invalid version requests, permission denied, disk full, network timeouts.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "40", + "name": "user-errors-piechart" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"user\"\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, error_details, os, version\n| order by timestamp desc\n| take 25", + "size": 1, + "title": "Recent User Errors", + "noDataMessage": "No user errors recorded yet.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "customWidth": "60", + "name": "recent-user-errors" + }, + { + "type": 1, + "content": { + "json": "## Performance" + }, + "name": "perf-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| where success == true\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n duration = todouble(customDimensions[\"duration_ms\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| summarize \n Count = count(),\n P50 = percentile(duration, 50),\n P90 = percentile(duration, 90),\n P99 = percentile(duration, 99),\n Avg = avg(duration)\n by command\n| project Command = command, \n Count,\n ['P50 (ms)'] = round(P50, 0), \n ['P90 (ms)'] = round(P90, 0), \n ['P99 (ms)'] = round(P99, 0),\n ['Avg (ms)'] = round(Avg, 0)", + "size": 1, + "title": "Command Duration Percentiles (Successful Only)", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "customWidth": "50", + "name": "duration-percentiles" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"download\" or name == \"extract\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend from_cache = tostring(customDimensions[\"download.from_cache\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| extend operation = case(\n name == \"download\" and (from_cache == \"true\" or from_cache == \"True\"), \"download (cached)\",\n name == \"download\", \"download (network)\",\n \"extract\")\n| summarize AvgDuration = avg(duration) by bin(timestamp, 1h), operation\n| render timechart", + "size": 1, + "title": "Download & Extract Duration Over Time", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "timechart" + }, + "customWidth": "50", + "name": "download-duration-timechart" + }, + { + "type": 1, + "content": { + "json": "## Unique Users" + }, + "name": "users-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend device_id = tostring(customDimensions[\"device.id\"]), version = tostring(customDimensions[\"dotnetup.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize \n UniqueDevices = dcount(device_id),\n TotalCommands = count()\n| project ['Unique Devices'] = UniqueDevices, ['Total Commands'] = TotalCommands, ['Avg Commands/Device'] = round(1.0 * TotalCommands / UniqueDevices, 1)", + "size": 4, + "title": "User Statistics", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "Unique Devices", + "formatter": 1 + }, + { + "columnMatch": "Total Commands", + "formatter": 1 + }, + { + "columnMatch": "Avg Commands/Device", + "formatter": 1 + } + ] + } + }, + "customWidth": "30", + "name": "user-stats" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend device_id = tostring(customDimensions[\"device.id\"]), version = tostring(customDimensions[\"dotnetup.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(version)\n| summarize UniqueDevices = dcount(device_id) by bin(timestamp, 1d), version\n| order by timestamp asc, version desc", + "size": 1, + "title": "Daily Unique Users by Version", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "barchart", + "chartSettings": { + "xAxis": "timestamp", + "yAxis": [ + "UniqueDevices" + ], + "group": "version", + "createOtherGroup": null, + "showLegend": true + } + }, + "customWidth": "35", + "name": "daily-users-by-version" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend device_id = tostring(customDimensions[\"device.id\"]), version = tostring(customDimensions[\"dotnetup.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize UniqueDevices = dcount(device_id) by bin(timestamp, 1d)\n| render timechart", + "size": 1, + "title": "Daily Active Devices", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "timechart" + }, + "customWidth": "35", + "name": "daily-active-devices" + } + ], + "fallbackResourceIds": [], + "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" +} From 1d88b2168cdf07ad4f1f391b1d63df68bba9ccfc Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 16:12:11 -0800 Subject: [PATCH 15/59] throw some more specific error types --- .../Internal/DotnetArchiveDownloader.cs | 30 ++++++++++--- .../Internal/DotnetArchiveExtractor.cs | 44 ++++++++++++++++++- .../Internal/ReleaseManifest.cs | 19 ++++++++ .../Commands/Sdk/Install/SdkInstallCommand.cs | 18 ++++---- .../dotnetup/DotnetInstallManager.cs | 6 +-- .../InstallerOrchestratorSingleton.cs | 21 ++++++--- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 4 ++ test/dotnetup.Tests/ErrorCodeMapperTests.cs | 4 ++ 8 files changed, 121 insertions(+), 25 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index a6fcddfcf4bd..29144f4da942 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -209,16 +209,34 @@ public void DownloadArchiveWithVerification( IProgressTask? telemetryTask = null) { var targetFile = _releaseManifest.FindReleaseFile(installRequest, resolvedVersion); - string? downloadUrl = targetFile?.Address.ToString(); - string? expectedHash = targetFile?.Hash.ToString(); + + if (targetFile == null) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.NoMatchingFile, + $"No matching file found for {installRequest.Component} version {resolvedVersion} on {installRequest.InstallRoot.Architecture}", + version: resolvedVersion.ToString(), + component: installRequest.Component.ToString()); + } + + string? downloadUrl = targetFile.Address.ToString(); + string? expectedHash = targetFile.Hash.ToString(); if (string.IsNullOrEmpty(expectedHash)) { - throw new ArgumentException($"{nameof(expectedHash)} cannot be null or empty"); + throw new DotnetInstallException( + DotnetInstallErrorCode.ManifestParseFailed, + $"No hash found in manifest for {resolvedVersion}", + version: resolvedVersion.ToString(), + component: installRequest.Component.ToString()); } if (string.IsNullOrEmpty(downloadUrl)) { - throw new ArgumentException($"{nameof(downloadUrl)} cannot be null or empty"); + throw new DotnetInstallException( + DotnetInstallErrorCode.ManifestParseFailed, + $"No download URL found in manifest for {resolvedVersion}", + version: resolvedVersion.ToString(), + component: installRequest.Component.ToString()); } // Set download URL domain for telemetry @@ -314,7 +332,9 @@ public static void VerifyFileHash(string filePath, string expectedHash) string actualHash = ComputeFileHash(filePath); if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase)) { - throw new Exception($"File hash mismatch. Expected: {expectedHash}, Actual: {actualHash}"); + throw new DotnetInstallException( + DotnetInstallErrorCode.HashMismatch, + $"File hash mismatch. Expected: {expectedHash}, Actual: {actualHash}"); } } diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index 36a14670177c..a4220facd3f9 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -7,6 +7,7 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Net.Http; using System.Runtime.InteropServices; using Microsoft.Deployment.DotNet.Releases; @@ -48,10 +49,29 @@ public void Prepare() downloadTask.Value = 100; downloadTask.Complete(); } + catch (DotnetInstallException) + { + throw; + } + catch (HttpRequestException ex) + { + downloadTask.RecordError(ex); + throw new DotnetInstallException( + DotnetInstallErrorCode.DownloadFailed, + $"Failed to download .NET archive for version {_resolvedVersion}: {ex.Message}", + ex, + version: _resolvedVersion.ToString(), + component: _request.Component.ToString()); + } catch (Exception ex) { downloadTask.RecordError(ex); - throw new Exception($"Failed to download .NET archive for version {_resolvedVersion}", ex); + throw new DotnetInstallException( + DotnetInstallErrorCode.DownloadFailed, + $"Failed to download .NET archive for version {_resolvedVersion}: {ex.Message}", + ex, + version: _resolvedVersion.ToString(), + component: _request.Component.ToString()); } } @@ -70,10 +90,30 @@ public void Commit() installTask.SetTag("extract.file_count", _extractedFileCount); installTask.Complete(); } + catch (DotnetInstallException) + { + throw; + } + catch (InvalidDataException ex) + { + // Archive is corrupted (invalid zip/tar format) + installTask.RecordError(ex); + throw new DotnetInstallException( + DotnetInstallErrorCode.ArchiveCorrupted, + $"Archive is corrupted or truncated for version {_resolvedVersion}: {ex.Message}", + ex, + version: _resolvedVersion.ToString(), + component: _request.Component.ToString()); + } catch (Exception ex) { installTask.RecordError(ex); - throw; + throw new DotnetInstallException( + DotnetInstallErrorCode.ExtractionFailed, + $"Failed to extract .NET archive for version {_resolvedVersion}: {ex.Message}", + ex, + version: _resolvedVersion.ToString(), + component: _request.Component.ToString()); } } diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index 77885ce2038f..f81488162786 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs @@ -3,6 +3,7 @@ using System; using System.IO; +using System.Net.Http; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation; @@ -47,6 +48,24 @@ public ReleaseManifest() { throw; } + catch (HttpRequestException ex) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.ManifestFetchFailed, + $"Failed to fetch release manifest: {ex.Message}", + ex, + version: resolvedVersion.ToString(), + component: installRequest.Component.ToString()); + } + catch (System.Text.Json.JsonException ex) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.ManifestParseFailed, + $"Failed to parse release manifest: {ex.Message}", + ex, + version: resolvedVersion.ToString(), + component: installRequest.Component.ToString()); + } catch (Exception ex) { throw new DotnetInstallException( diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs index 4b83d286c728..d667f81ba8c1 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -265,18 +265,20 @@ protected override int ExecuteCore() SpectreAnsiConsole.MarkupLineInterpolated($"Installing .NET SDK [blue]{resolvedVersion}[/] to [blue]{resolvedInstallPath}[/]..."); - DotnetInstall? mainInstall; - // Pass the _noProgress flag to the InstallerOrchestratorSingleton // The orchestrator will handle installation with or without progress based on the flag - mainInstall = InstallerOrchestratorSingleton.Instance.Install(installRequest, _noProgress); - if (mainInstall == null) + var installResult = InstallerOrchestratorSingleton.Instance.Install(installRequest, _noProgress); + if (installResult.Install == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedVersion}[/]"); RecordFailure("install_failed", $"Failed to install SDK {resolvedVersion}"); return 1; } - SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {mainInstall.Version}, available via {mainInstall.InstallRoot}[/]"); + + // Record installation outcome for telemetry + SetCommandTag("install.result", installResult.WasAlreadyInstalled ? "already_installed" : "installed"); + + SpectreAnsiConsole.MarkupLine($"[green]Installed .NET SDK {installResult.Install.Version}, available via {installResult.Install.InstallRoot}[/]"); // Install any additional versions foreach (var additionalVersion in additionalVersionsToInstall) @@ -292,14 +294,14 @@ protected override int ExecuteCore() }); // Install the additional version with the same progress settings as the main installation - DotnetInstall? additionalInstall = InstallerOrchestratorSingleton.Instance.Install(additionalRequest); - if (additionalInstall == null) + var additionalResult = InstallerOrchestratorSingleton.Instance.Install(additionalRequest); + if (additionalResult.Install == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install additional .NET SDK {additionalVersion}[/]"); } else { - SpectreAnsiConsole.MarkupLine($"[green]Installed additional .NET SDK {additionalInstall.Version}, available via {additionalInstall.InstallRoot}[/]"); + SpectreAnsiConsole.MarkupLine($"[green]Installed additional .NET SDK {additionalResult.Install.Version}, available via {additionalResult.Install.InstallRoot}[/]"); } } diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index bf20e23d8376..15bc0d58352f 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -125,14 +125,14 @@ private void InstallSDK(DotnetInstallRoot dotnetRoot, ProgressContext progressCo new InstallRequestOptions() ); - DotnetInstall? newInstall = InstallerOrchestratorSingleton.Instance.Install(request); - if (newInstall == null) + var installResult = InstallerOrchestratorSingleton.Instance.Install(request); + if (installResult.Install == null) { throw new Exception($"Failed to install .NET SDK {channnel.Name}"); } else { - Spectre.Console.AnsiConsole.MarkupLine($"[green]Installed .NET SDK {newInstall.Version}, available via {newInstall.InstallRoot}[/]"); + Spectre.Console.AnsiConsole.MarkupLine($"[green]Installed .NET SDK {installResult.Install.Version}, available via {installResult.Install.InstallRoot}[/]"); } } diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index 3d6ced5256ee..f5ef3a82df84 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -9,6 +9,13 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; +/// +/// Result of an installation operation. +/// +/// The DotnetInstall, or null if installation failed. +/// True if the SDK was already installed and no work was done. +internal sealed record InstallResult(DotnetInstall? Install, bool WasAlreadyInstalled); + internal class InstallerOrchestratorSingleton { private static readonly InstallerOrchestratorSingleton _instance = new(); @@ -21,8 +28,8 @@ private InstallerOrchestratorSingleton() private ScopedMutex modifyInstallStateMutex() => new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); - // Returns null on failure, DotnetInstall on success - public DotnetInstall? Install(DotnetInstallRequest installRequest, bool noProgress = false) + // Returns InstallResult with Install=null on failure, or Install=DotnetInstall on success + public InstallResult Install(DotnetInstallRequest installRequest, bool noProgress = false) { // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version ReleaseManifest releaseManifest = new(); @@ -31,7 +38,7 @@ private InstallerOrchestratorSingleton() if (versionToInstall == null) { Console.WriteLine($"\nCould not resolve version for channel '{installRequest.Channel.Name}'."); - return null; + return new InstallResult(null, WasAlreadyInstalled: false); } DotnetInstall install = new( @@ -48,7 +55,7 @@ private InstallerOrchestratorSingleton() if (InstallAlreadyExists(install, customManifestPath)) { Console.WriteLine($"\n.NET SDK {versionToInstall} is already installed, skipping installation."); - return install; + return new InstallResult(install, WasAlreadyInstalled: true); } } @@ -62,7 +69,7 @@ private InstallerOrchestratorSingleton() { if (InstallAlreadyExists(install, customManifestPath)) { - return install; + return new InstallResult(install, WasAlreadyInstalled: true); } installer.Commit(); @@ -75,11 +82,11 @@ private InstallerOrchestratorSingleton() } else { - return null; + return new InstallResult(null, WasAlreadyInstalled: false); } } - return install; + return new InstallResult(install, WasAlreadyInstalled: false); } /// diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 2e8130c50d65..1f747f0eed94 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -185,6 +185,10 @@ private static ErrorCategory GetInstallErrorCategory(DotnetInstallErrorCode erro DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue + DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, // Server unreachable or CDN issue + DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug + DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download + DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, // Our locking mechanism issue DotnetInstallErrorCode.Unknown => ErrorCategory.Product, // Unknown = assume product issue _ => ErrorCategory.Product // Default to product for new codes diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index af04831b43d8..534f9f14e161 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -290,6 +290,10 @@ public void GetErrorInfo_AlreadyExists_MapsCorrectly() [InlineData(DotnetInstallErrorCode.DownloadFailed, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.HashMismatch, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.ExtractionFailed, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.ManifestFetchFailed, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.ManifestParseFailed, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.ArchiveCorrupted, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.InstallationLocked, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.Unknown, ErrorCategory.Product)] public void GetErrorInfo_DotnetInstallException_HasCorrectCategory(DotnetInstallErrorCode errorCode, ErrorCategory expectedCategory) { From d47f9849646e9b02c5deb86b358ac785f8a3bcd3 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 16:20:52 -0800 Subject: [PATCH 16/59] collect further insights that will drive decisions --- .../DotnetInstallException.cs | 6 ++ .../Internal/DotnetArchiveExtractor.cs | 8 ++ .../Internal/ScopedMutex.cs | 14 +++- .../Commands/Sdk/Install/SdkInstallCommand.cs | 80 ++++++++++++++++++- .../dotnetup/DotnetupSharedManifest.cs | 20 ++++- .../InstallerOrchestratorSingleton.cs | 12 +++ .../dotnetup/Telemetry/ErrorCodeMapper.cs | 2 + test/dotnetup.Tests/ErrorCodeMapperTests.cs | 2 + 8 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs index 791d30e368a7..43c92262685c 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs @@ -52,6 +52,12 @@ public enum DotnetInstallErrorCode /// Another installation process is already running. InstallationLocked, + + /// Failed to read/write the dotnetup installation manifest. + LocalManifestError, + + /// The dotnetup installation manifest is corrupted. + LocalManifestCorrupted, } /// diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index a4220facd3f9..9986ac992da2 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -170,6 +170,7 @@ private void ExtractArchiveDirectlyToTarget(string archivePath, string targetDir File.Delete(muxerTargetPath); } File.Move(muxerTempPath, muxerTargetPath); + installTask?.SetTag("muxer.action", "kept_existing"); } else { @@ -178,8 +179,15 @@ private void ExtractArchiveDirectlyToTarget(string archivePath, string targetDir { File.Delete(muxerTempPath); } + installTask?.SetTag("muxer.action", "updated"); + installTask?.SetTag("muxer.previous_version", existingMuxerVersion?.ToString() ?? "unknown"); + installTask?.SetTag("muxer.new_version", newMuxerVersion?.ToString() ?? "unknown"); } } + else if (!hadExistingMuxer) + { + installTask?.SetTag("muxer.action", "new_install"); + } } catch { diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs index a076efff7182..110848b0b3df 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs @@ -13,6 +13,11 @@ public class ScopedMutex : IDisposable // Track recursive holds on a per-thread basis so we can assert manifest access without re-acquiring. private static readonly ThreadLocal _holdCount = new(() => 0); + /// + /// Timeout in seconds for mutex acquisition. Default is 5 minutes. + /// + public static int TimeoutSeconds { get; set; } = 300; + public ScopedMutex(string name) { // On Linux and Mac, "Global\" prefix doesn't work - strip it if present @@ -23,11 +28,16 @@ public ScopedMutex(string name) } _mutex = new Mutex(false, mutexName); - _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(300), false); + _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(TimeoutSeconds), false); if (_hasHandle) { _holdCount.Value = _holdCount.Value + 1; } + // Note: If _hasHandle is false, caller should check HasHandle property. + // We don't throw here because: + // 1. The mutex may be acquired multiple times in a single process flow + // 2. The caller may want to handle the failure gracefully + // Telemetry for lock contention is recorded by the caller when HasHandle is false. } public bool HasHandle => _hasHandle; @@ -52,4 +62,4 @@ public void Dispose() } _mutex.Dispose(); } -} +} \ No newline at end of file diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs index d667f81ba8c1..947b4a8de930 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -34,11 +34,21 @@ protected override int ExecuteCore() // Record the raw requested version with PII sanitization (for backwards compatibility) RecordRequestedVersion(_versionOrChannel); + // Track if user explicitly specified install path via -d option + SetCommandTag("install.path_explicit", _installPath is not null); + var globalJsonInfo = _dotnetInstaller.GetGlobalJsonInfo(Environment.CurrentDirectory); + // Track if global.json was present in the project + SetCommandTag("install.has_global_json", globalJsonInfo?.GlobalJsonPath is not null); + var currentDotnetInstallRoot = _dotnetInstaller.GetConfiguredInstallType(); + // Track the current install configuration state + SetCommandTag("install.existing_install_type", currentDotnetInstallRoot?.InstallType.ToString() ?? "none"); + string? resolvedInstallPath = null; + string installPathSource = "default"; // Track how install path was determined string? installPathFromGlobalJson = null; if (globalJsonInfo?.GlobalJsonPath is not null) @@ -55,17 +65,20 @@ protected override int ExecuteCore() } resolvedInstallPath = installPathFromGlobalJson; + installPathSource = "global_json"; } - if (resolvedInstallPath == null) + if (resolvedInstallPath == null && _installPath is not null) { resolvedInstallPath = _installPath; + installPathSource = "explicit"; // User specified -d / --install-path } if (resolvedInstallPath == null && currentDotnetInstallRoot is not null && currentDotnetInstallRoot.InstallType == InstallType.User) { // If a user installation is already set up, we don't need to prompt for the install path resolvedInstallPath = currentDotnetInstallRoot.Path; + installPathSource = "existing_user_install"; } if (resolvedInstallPath == null) @@ -75,14 +88,20 @@ protected override int ExecuteCore() resolvedInstallPath = SpectreAnsiConsole.Prompt( new TextPrompt("Where should we install the .NET SDK to?)") .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); + installPathSource = "interactive_prompt"; } else { // If no install path is specified, use the default install path resolvedInstallPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); + installPathSource = "default"; } } + // Record install path source and type classification + SetCommandTag("install.path_source", installPathSource); + SetCommandTag("install.path_type", ClassifyInstallPath(resolvedInstallPath)); + string? channelFromGlobalJson = null; if (globalJsonInfo?.GlobalJsonPath is not null) { @@ -240,6 +259,9 @@ protected override int ExecuteCore() if (resolvedSetDefaultInstall == true && currentDotnetInstallRoot?.InstallType == InstallType.Admin) { + // Track admin-to-user migration scenario + SetCommandTag("install.migrating_from_admin", true); + if (_interactive) { var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); @@ -252,6 +274,7 @@ protected override int ExecuteCore() defaultValue: true)) { additionalVersionsToInstall.Add(latestAdminVersion); + SetCommandTag("install.admin_version_copied", true); } } } @@ -332,4 +355,57 @@ protected override int ExecuteCore() return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } -} + /// + /// Classifies the install path for telemetry (no PII - just the type). + /// + private static string ClassifyInstallPath(string path) + { + var fullPath = Path.GetFullPath(path); + + if (OperatingSystem.IsWindows()) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + // Check if user is trying to install to a system path (Program Files) + if (!string.IsNullOrEmpty(programFiles) && fullPath.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase)) + { + return "system_programfiles"; + } + if (!string.IsNullOrEmpty(programFilesX86) && fullPath.StartsWith(programFilesX86, StringComparison.OrdinalIgnoreCase)) + { + return "system_programfiles_x86"; + } + + // Check if it's in user profile + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(userProfile) && fullPath.StartsWith(userProfile, StringComparison.OrdinalIgnoreCase)) + { + return "user_profile"; + } + + // Check if it's in local app data (default location) + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrEmpty(localAppData) && fullPath.StartsWith(localAppData, StringComparison.OrdinalIgnoreCase)) + { + return "local_appdata"; + } + } + else + { + // Unix-like systems + if (fullPath.StartsWith("/usr/", StringComparison.Ordinal) || + fullPath.StartsWith("/opt/", StringComparison.Ordinal)) + { + return "system_path"; + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home) && fullPath.StartsWith(home, StringComparison.Ordinal)) + { + return "user_home"; + } + } + + return "other"; + }} \ No newline at end of file diff --git a/src/Installer/dotnetup/DotnetupSharedManifest.cs b/src/Installer/dotnetup/DotnetupSharedManifest.cs index ffffffae9306..cade44a02ed4 100644 --- a/src/Installer/dotnetup/DotnetupSharedManifest.cs +++ b/src/Installer/dotnetup/DotnetupSharedManifest.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text.Json; using System.Threading; +using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -70,7 +71,19 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v AssertHasFinalizationMutex(); EnsureManifestExists(); - var json = File.ReadAllText(ManifestPath); + string json; + try + { + json = File.ReadAllText(ManifestPath); + } + catch (IOException ex) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.LocalManifestError, + $"Failed to read dotnetup manifest at {ManifestPath}: {ex.Message}", + ex); + } + try { var installs = JsonSerializer.Deserialize(json, DotnetupManifestJsonContext.Default.ListDotnetInstall); @@ -90,7 +103,10 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v } catch (JsonException ex) { - throw new InvalidOperationException($"The dotnetup manifest is corrupt or inaccessible", ex); + throw new DotnetInstallException( + DotnetInstallErrorCode.LocalManifestCorrupted, + $"The dotnetup manifest at {ManifestPath} is corrupt. Consider deleting it and re-running the install.", + ex); } } diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index f5ef3a82df84..ea45d2196fc6 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -52,6 +52,12 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres // read write mutex only for manifest? using (var finalizeLock = modifyInstallStateMutex()) { + if (!finalizeLock.HasHandle) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InstallationLocked, + $"Could not acquire installation lock. Another dotnetup or installation process may be running."); + } if (InstallAlreadyExists(install, customManifestPath)) { Console.WriteLine($"\n.NET SDK {versionToInstall} is already installed, skipping installation."); @@ -67,6 +73,12 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres // Extract and commit the install to the directory using (var finalizeLock = modifyInstallStateMutex()) { + if (!finalizeLock.HasHandle) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InstallationLocked, + $"Could not acquire installation lock. Another dotnetup or installation process may be running."); + } if (InstallAlreadyExists(install, customManifestPath)) { return new InstallResult(install, WasAlreadyInstalled: true); diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 1f747f0eed94..6a043a5ee984 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -189,6 +189,8 @@ private static ErrorCategory GetInstallErrorCategory(DotnetInstallErrorCode erro DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, // Our locking mechanism issue + DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, // File system issue with our manifest + DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, // Our manifest is corrupt - we should handle DotnetInstallErrorCode.Unknown => ErrorCategory.Product, // Unknown = assume product issue _ => ErrorCategory.Product // Default to product for new codes diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index 534f9f14e161..bb807170628b 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -294,6 +294,8 @@ public void GetErrorInfo_AlreadyExists_MapsCorrectly() [InlineData(DotnetInstallErrorCode.ManifestParseFailed, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.ArchiveCorrupted, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.InstallationLocked, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.LocalManifestError, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.LocalManifestCorrupted, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.Unknown, ErrorCategory.Product)] public void GetErrorInfo_DotnetInstallException_HasCorrectCategory(DotnetInstallErrorCode errorCode, ErrorCategory expectedCategory) { From 11054018326be5b30f4ac98397e6567936cd96cd Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 16:27:50 -0800 Subject: [PATCH 17/59] error categories should be presentt --- src/Installer/dotnetup/CommandBase.cs | 4 +++- .../dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs | 4 ++-- src/Installer/dotnetup/InstallerOrchestratorSingleton.cs | 8 ++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index a9454e6bc7d7..905874549096 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -95,9 +95,11 @@ protected void SetCommandTag(string key, object? value) /// /// A short error reason code (e.g., "path_mismatch", "download_failed"). /// Optional detailed error message. - protected void RecordFailure(string reason, string? message = null) + /// Error category: "user" for input/environment issues, "product" for bugs (default). + protected void RecordFailure(string reason, string? message = null, string category = "product") { _commandActivity?.SetTag("error.type", reason); + _commandActivity?.SetTag("error.category", category); if (message != null) { _commandActivity?.SetTag("error.message", message); diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs index 947b4a8de930..970f08d75e9f 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -60,7 +60,7 @@ protected override int ExecuteCore() { // TODO: Add parameter to override error Console.Error.WriteLine($"Error: The install path specified in global.json ({installPathFromGlobalJson}) does not match the install path provided ({_installPath})."); - RecordFailure("path_mismatch", $"global.json path ({installPathFromGlobalJson}) != provided path ({_installPath})"); + RecordFailure("path_mismatch", $"global.json path ({installPathFromGlobalJson}) != provided path ({_installPath})", category: "user"); return 1; } @@ -294,7 +294,7 @@ protected override int ExecuteCore() if (installResult.Install == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install .NET SDK {resolvedVersion}[/]"); - RecordFailure("install_failed", $"Failed to install SDK {resolvedVersion}"); + RecordFailure("install_failed", $"Failed to install SDK {resolvedVersion}", category: "product"); return 1; } diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index ea45d2196fc6..3cd2fa3922d9 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.Deployment.DotNet.Releases; +using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -37,8 +38,11 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres if (versionToInstall == null) { - Console.WriteLine($"\nCould not resolve version for channel '{installRequest.Channel.Name}'."); - return new InstallResult(null, WasAlreadyInstalled: false); + throw new DotnetInstallException( + DotnetInstallErrorCode.VersionNotFound, + $"Could not resolve version for channel '{installRequest.Channel.Name}'. The channel may be invalid or unsupported.", + version: installRequest.Channel.Name, + component: installRequest.Component.ToString()); } DotnetInstall install = new( From c92b39cea9db169bd0b37468780921d40b961006 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 16:34:05 -0800 Subject: [PATCH 18/59] filter metric should be correct --- src/Installer/dotnetup/Telemetry/dotnetup-workbook.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index 9da69520b9a0..e6c2dd9a945f 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -90,7 +90,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\nlet filteredData = dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter);\nlet versions = filteredData\n| where isnotempty(version)\n| distinct version\n| order by version desc\n| take 2\n| extend rank = row_number();\nlet latestVersion = toscalar(versions | where rank == 1 | project version);\nlet priorVersion = toscalar(versions | where rank == 2 | project version);\nlet latestStats = filteredData\n| where version == latestVersion\n| summarize \n Total = count(), \n Successful = countif(success == true), \n ProductErrors = countif(success == false and error_category == \"product\"),\n UserErrors = countif(success == false and error_category == \"user\")\n| extend \n Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1), \n Version = latestVersion, \n Label = \"Latest\";\nlet priorStats = filteredData\n| where version == priorVersion and isnotempty(priorVersion)\n| summarize \n Total = count(), \n Successful = countif(success == true), \n ProductErrors = countif(success == false and error_category == \"product\"),\n UserErrors = countif(success == false and error_category == \"user\")\n| extend \n Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1), \n Version = priorVersion, \n Label = \"Prior\";\nlatestStats\n| union priorStats\n| where isnotempty(Version)\n| project Label, Version, ['Success Rate'] = strcat(SuccessRate, '%'), Relevant, Successful, ['Product Errors'] = ProductErrors, ['User Errors'] = UserErrors\n| order by Label asc", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\nlet filteredData = dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter);\nlet versions = filteredData\n| where isnotempty(version)\n| distinct version\n| order by version desc\n| take 2\n| extend rank = row_number();\nlet latestVersion = toscalar(versions | where rank == 1 | project version);\nlet priorVersion = toscalar(versions | where rank == 2 | project version);\nlet latestStats = filteredData\n| where version == latestVersion\n| summarize \n Total = count(), \n Successful = countif(success == true), \n ProductErrors = countif(success == false and (error_category == \"product\" or isempty(error_category))),\n UserErrors = countif(success == false and error_category == \"user\")\n| extend \n Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1), \n Version = latestVersion, \n Label = \"Latest\";\nlet priorStats = filteredData\n| where version == priorVersion and isnotempty(priorVersion)\n| summarize \n Total = count(), \n Successful = countif(success == true), \n ProductErrors = countif(success == false and (error_category == \"product\" or isempty(error_category))),\n UserErrors = countif(success == false and error_category == \"user\")\n| extend \n Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1), \n Version = priorVersion, \n Label = \"Prior\";\nlatestStats\n| union priorStats\n| where isnotempty(Version)\n| project Label, Version, ['Success Rate'] = strcat(SuccessRate, '%'), Relevant, Successful, ['Product Errors'] = ProductErrors, ['User Errors'] = UserErrors\n| order by Label asc", "size": 1, "title": "Product Success Rate by Version", "queryType": 0, @@ -156,7 +156,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n version = coalesce(tostring(customDimensions[\"dotnetup.version\"]), \"unknown\"),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(command)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize \n Total = count(),\n Successful = countif(success == true),\n ProductErrors = countif(success == false and error_category == \"product\"),\n UserErrors = countif(success == false and error_category == \"user\")\n by command, version\n| where version != \"unknown\" or Total >= 25\n| extend Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1)\n| order by command asc, version desc\n| project Command = command, Version = version, ['Success Rate'] = SuccessRate, Relevant, Successful, ['Product Errors'] = ProductErrors, ['User Errors'] = UserErrors", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n version = coalesce(tostring(customDimensions[\"dotnetup.version\"]), \"unknown\"),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(command)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize \n Total = count(),\n Successful = countif(success == true),\n ProductErrors = countif(success == false and (error_category == \"product\" or isempty(error_category))),\n UserErrors = countif(success == false and error_category == \"user\")\n by command, version\n| where version != \"unknown\" or Total >= 25\n| extend Relevant = Total - UserErrors,\n SuccessRate = round(100.0 * Successful / (Total - UserErrors), 1)\n| order by command asc, version desc\n| project Command = command, Version = version, ['Success Rate'] = SuccessRate, Relevant, Successful, ['Product Errors'] = ProductErrors, ['User Errors'] = UserErrors", "size": 1, "title": "Product Success Rate by Command & Version", "queryType": 0, @@ -201,7 +201,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\nlet baseData = dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"]),\n command = tostring(customDimensions[\"command.name\"]),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true);\nlet latestVersion = toscalar(baseData | where isnotempty(version) | summarize arg_max(timestamp, version) | project version);\nlet targetVersion = iif(versionFilter == 'all', latestVersion, versionFilter);\nbaseData\n| where version == targetVersion\n| where isnotempty(command)\n| summarize \n Total = count(),\n Successful = countif(success == true),\n UserErrors = countif(success == false and error_category == \"user\")\n by bin(timestamp, 1d), command\n| extend SuccessRate = 100.0 * Successful / (Total - UserErrors)\n| project timestamp, command, SuccessRate\n| render timechart", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\nlet baseData = dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"]),\n command = tostring(customDimensions[\"command.name\"]),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true);\nlet latestVersion = toscalar(baseData | where isnotempty(version) | summarize arg_max(timestamp, version) | project version);\nlet targetVersion = iif(versionFilter == 'all', latestVersion, versionFilter);\nbaseData\n| where version == targetVersion\n| where isnotempty(command)\n| summarize \n Total = count(),\n Successful = countif(success == true),\n UserErrors = countif(success == false and error_category == \"user\")\n by bin(timestamp, 1d), command\n| where (Total - UserErrors) > 0\n| extend SuccessRate = 100.0 * Successful / (Total - UserErrors)\n| project timestamp, command, SuccessRate\n| render timechart", "size": 1, "title": "Product Success Rate Over Time (by Command)", "queryType": 0, From 6d0ec377658a69262fcf2fc0e100f92a24402057 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 30 Jan 2026 16:36:13 -0800 Subject: [PATCH 19/59] don't track data we don't need or want --- src/Installer/dotnetup/CommandBase.cs | 3 +-- .../dotnetup/InstallerOrchestratorSingleton.cs | 6 ++++-- src/Installer/dotnetup/NonUpdatingProgressTarget.cs | 7 +++++-- src/Installer/dotnetup/SpectreProgressTarget.cs | 13 ++++++++----- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 9 +++++++-- src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs | 11 +++++++---- 6 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index 905874549096..8dfce8a0ef6a 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -51,8 +51,7 @@ public int Execute() _commandActivity?.SetTag("duration_ms", stopwatch.Elapsed.TotalMilliseconds); _commandActivity?.SetTag("exit.code", 1); DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); - // Activity status is set inside RecordException, but explicitly set it here too - _commandActivity?.SetStatus(ActivityStatusCode.Error, ex.Message); + // Status is already set inside RecordException with error type (no PII) throw; } finally diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index 3cd2fa3922d9..86c1ce83059e 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -38,10 +38,12 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres if (versionToInstall == null) { + // Don't pass raw user input to exception - it goes to telemetry + // Just report that the version couldn't be resolved throw new DotnetInstallException( DotnetInstallErrorCode.VersionNotFound, - $"Could not resolve version for channel '{installRequest.Channel.Name}'. The channel may be invalid or unsupported.", - version: installRequest.Channel.Name, + $"Could not resolve version for the specified channel. The channel may be invalid or unsupported.", + version: null, // Don't include user input in telemetry component: installRequest.Component.ToString()); } diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index c4b643ab1729..56aacb4cdb94 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -83,8 +83,10 @@ public void RecordError(Exception ex) // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - _activity.SetStatus(ActivityStatusCode.Error, ex.Message); + // Don't pass ex.Message - it can contain PII (paths, user input) + _activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); _activity.SetTag("error.type", errorInfo.ErrorType); + _activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); if (errorInfo.StatusCode.HasValue) { @@ -111,7 +113,8 @@ public void RecordError(Exception ex) _activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); } - _activity.RecordException(ex); + // NOTE: We intentionally do NOT call _activity.RecordException(ex) + // because exception messages/stacks can contain PII } public void Complete() diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index 771e628de34c..e4c675769963 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -93,13 +93,15 @@ public double MaxValue public void RecordError(Exception ex) { if (_activity == null) return; - + // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - - _activity.SetStatus(ActivityStatusCode.Error, ex.Message); + + // Don't pass ex.Message - it can contain PII (paths, user input) + _activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); _activity.SetTag("error.type", errorInfo.ErrorType); - + _activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); + if (errorInfo.StatusCode.HasValue) { _activity.SetTag("error.http_status", errorInfo.StatusCode.Value); @@ -125,7 +127,8 @@ public void RecordError(Exception ex) _activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); } - _activity.RecordException(ex); + // NOTE: We intentionally do NOT call _activity.RecordException(ex) + // because exception messages/stacks can contain PII } public void Complete() diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index 55633089b3fc..35343fb34a08 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -146,7 +146,9 @@ public void RecordException(Activity? activity, Exception ex, string? errorCode var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - activity.SetStatus(ActivityStatusCode.Error, ex.Message); + // Don't pass ex.Message to SetStatus - it can contain PII (paths, user input, etc.) + // Use the error type as the status description instead + activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); activity.SetTag("error.type", errorInfo.ErrorType); activity.SetTag("error.code", errorCode ?? errorInfo.ErrorType); activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); @@ -176,7 +178,10 @@ public void RecordException(Activity? activity, Exception ex, string? errorCode activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); } - activity.RecordException(ex); + // NOTE: We intentionally do NOT call activity.RecordException(ex) because: + // 1. Exception messages can contain PII (file paths, user input, etc.) + // 2. Stack traces contain internal implementation details + // 3. We capture all relevant info via sanitized tags above } /// diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 6a043a5ee984..d47af08f29d3 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -6,6 +6,7 @@ using System.Net; using System.Runtime.InteropServices; using Microsoft.Dotnet.Installation; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; @@ -78,10 +79,11 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) return ex switch { // DotnetInstallException has specific error codes - categorize by error code + // Sanitize the version to prevent PII leakage (user could have typed anything) DotnetInstallException installEx => new ExceptionErrorInfo( installEx.ErrorCode.ToString(), Category: GetInstallErrorCategory(installEx.ErrorCode), - Details: installEx.Version, + Details: installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), @@ -227,17 +229,18 @@ private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourc string? details; ErrorCategory category; - // On Windows, use Win32Exception to get the readable error message + // On Windows, use HResult to derive error type if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && ioEx.HResult != 0) { // Extract the Win32 error code from HResult (lower 16 bits) var win32ErrorCode = ioEx.HResult & 0xFFFF; - var win32Ex = new Win32Exception(win32ErrorCode); - details = win32Ex.Message; // Derive a short error type from the HResult errorType = GetWindowsErrorType(ioEx.HResult); category = GetIOErrorCategory(errorType); + // Don't use win32Ex.Message - it can contain paths/PII + // Just use the error code for details + details = $"win32_error_{win32ErrorCode}"; } else { From 9df5a81d43efb34c6049099f4bf62dbbea7df46f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 10:09:52 -0800 Subject: [PATCH 20/59] Include line number for error I also initially included the sha but I want to be able to sort by error an dont have to parse out the sha which should be mappable to /from the version. Still, I kept the outsource of the build sha to a separate file bc I liked that isolated shareable pattern. --- .../dotnetup/Commands/Info/InfoCommand.cs | 31 +------- src/Installer/dotnetup/Telemetry/BuildInfo.cs | 79 +++++++++++++++++++ .../dotnetup/Telemetry/ErrorCodeMapper.cs | 16 +++- 3 files changed, 94 insertions(+), 32 deletions(-) create mode 100644 src/Installer/dotnetup/Telemetry/BuildInfo.cs diff --git a/src/Installer/dotnetup/Commands/Info/InfoCommand.cs b/src/Installer/dotnetup/Commands/Info/InfoCommand.cs index 08aaf740405f..9963fd8a12af 100644 --- a/src/Installer/dotnetup/Commands/Info/InfoCommand.cs +++ b/src/Installer/dotnetup/Commands/Info/InfoCommand.cs @@ -2,11 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.DotNet.Tools.Bootstrapper.Commands.List; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Info; @@ -51,40 +51,15 @@ protected override int ExecuteCore() private static DotnetupInfo GetDotnetupInfo() { - var assembly = Assembly.GetExecutingAssembly(); - var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion ?? "unknown"; - - // InformationalVersion format is typically "version+commitsha" when SourceLink is enabled - var (version, commit) = ParseInformationalVersion(informationalVersion); - return new DotnetupInfo { - Version = version, - Commit = commit, + Version = BuildInfo.Version, + Commit = BuildInfo.CommitSha, Architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), Rid = RuntimeInformation.RuntimeIdentifier }; } - private static (string Version, string Commit) ParseInformationalVersion(string informationalVersion) - { - // Format: "1.0.0+abc123d" or just "1.0.0" - var plusIndex = informationalVersion.IndexOf('+'); - if (plusIndex > 0) - { - var version = informationalVersion.Substring(0, plusIndex); - var commit = informationalVersion.Substring(plusIndex + 1); - // Truncate commit to 7 characters for display (git's standard short SHA) - if (commit.Length > 7) - { - commit = commit.Substring(0, 7); - } - return (version, commit); - } - - return (informationalVersion, "N/A"); - } - private static void PrintHumanReadableInfo(TextWriter output, DotnetupInfo info, List? installations) { output.WriteLine(Strings.InfoHeader); diff --git a/src/Installer/dotnetup/Telemetry/BuildInfo.cs b/src/Installer/dotnetup/Telemetry/BuildInfo.cs new file mode 100644 index 000000000000..519d35c2c4b4 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/BuildInfo.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Provides build information extracted from assembly metadata. +/// +public static class BuildInfo +{ + private static string? _version; + private static string? _commitSha; + private static bool _initialized; + + /// + /// Gets the version string (e.g., "1.0.0"). + /// + public static string Version + { + get + { + EnsureInitialized(); + return _version!; + } + } + + /// + /// Gets the short commit SHA (7 characters, e.g., "abc123d"). + /// Returns "unknown" if not available. + /// + public static string CommitSha + { + get + { + EnsureInitialized(); + return _commitSha!; + } + } + + private static void EnsureInitialized() + { + if (_initialized) + { + return; + } + + var assembly = Assembly.GetExecutingAssembly(); + var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion ?? "unknown"; + + (_version, _commitSha) = ParseInformationalVersion(informationalVersion); + _initialized = true; + } + + /// + /// Parses the informational version string to extract version and commit SHA. + /// + /// The informational version string (e.g., "1.0.0+abc123def"). + /// A tuple of (version, commitSha). + internal static (string Version, string CommitSha) ParseInformationalVersion(string informationalVersion) + { + // Format: "1.0.0+abc123d" or just "1.0.0" + var plusIndex = informationalVersion.IndexOf('+'); + if (plusIndex > 0) + { + var version = informationalVersion.Substring(0, plusIndex); + var commit = informationalVersion.Substring(plusIndex + 1); + // Truncate commit to 7 characters (git's standard short SHA) + if (commit.Length > 7) + { + commit = commit.Substring(0, 7); + } + return (version, commit); + } + + return (informationalVersion, "unknown"); + } +} diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index d47af08f29d3..f8651a76fd8e 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -361,13 +361,13 @@ private static (string errorType, string? details) GetErrorTypeFromHResult(int h /// /// Gets a safe source location from the stack trace - finds the first frame from our assemblies. /// This is typically the code in dotnetup that called into BCL/external code that threw. - /// No file paths or line numbers that could contain user info. + /// No file paths that could contain user info. Line numbers from our code are included as they are not PII. /// private static string? GetSafeSourceLocation(Exception ex) { try { - var stackTrace = new StackTrace(ex, fNeedFileInfo: false); + var stackTrace = new StackTrace(ex, fNeedFileInfo: true); var frames = stackTrace.GetFrames(); if (frames == null || frames.Length == 0) @@ -401,12 +401,20 @@ private static (string errorType, string? details) GetErrorTypeFromHResult(int h // Extract just the type name (last part after the last dot, before any generic params) var typeName = ExtractTypeName(declaringType); - // Return "TypeName.MethodName" - no paths, no line numbers - return $"{typeName}.{methodInfo.Name}"; + // Include line number for our code (not PII), but never file paths + // Also include commit SHA so line numbers can be correlated to source + var lineNumber = frame.GetFileLineNumber(); + var location = $"{typeName}.{methodInfo.Name}"; + if (lineNumber > 0) + { + location += $":{lineNumber}"; + } + return location; } } // If we didn't find our code, return the throw site as a fallback + // This code is managed by dotnetup and not the library so we expect the throwsite to only be our own dependent code we call into return throwSite; } catch From 1977c58c3f3a26a2fc396bb62e6e9a5cd5d5f7ea Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 10:18:15 -0800 Subject: [PATCH 21/59] Consolidate logic which generates paths for local dotnetup storage. --- src/Installer/dotnetup/DotnetupPaths.cs | 124 ++++++++++++++++++ .../dotnetup/DotnetupSharedManifest.cs | 20 +-- .../dotnetup/Telemetry/FirstRunNotice.cs | 43 +----- 3 files changed, 131 insertions(+), 56 deletions(-) create mode 100644 src/Installer/dotnetup/DotnetupPaths.cs diff --git a/src/Installer/dotnetup/DotnetupPaths.cs b/src/Installer/dotnetup/DotnetupPaths.cs new file mode 100644 index 000000000000..f1078c857404 --- /dev/null +++ b/src/Installer/dotnetup/DotnetupPaths.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Provides canonical paths for dotnetup data directories and files. +/// Centralizes path logic to ensure consistency across the application. +/// +internal static class DotnetupPaths +{ + private const string DotnetupFolderName = "dotnetup"; + private const string ManifestFileName = "dotnetup_manifest.json"; + private const string TelemetrySentinelFileName = ".dotnetup-telemetry-notice"; + + private static string? _dataDirectory; + + /// + /// Gets the base data directory for dotnetup. + /// On Windows: %LOCALAPPDATA%\dotnetup + /// On Unix: ~/.dotnetup (hidden folder in user profile) + /// + /// + /// Returns null if the base directory cannot be determined. + /// + public static string? DataDirectory + { + get + { + if (_dataDirectory is not null) + { + return _dataDirectory; + } + + var baseDir = GetBaseDirectory(); + if (string.IsNullOrEmpty(baseDir)) + { + return null; + } + + _dataDirectory = Path.Combine(baseDir, DotnetupFolderName); + return _dataDirectory; + } + } + + /// + /// Gets the path to the dotnetup manifest file. + /// + /// + /// Returns null if the data directory cannot be determined. + /// Can be overridden via DOTNET_TESTHOOK_MANIFEST_PATH environment variable. + /// + public static string? ManifestPath + { + get + { + // Allow override for testing + var overridePath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH"); + if (!string.IsNullOrEmpty(overridePath)) + { + return overridePath; + } + + var dataDir = DataDirectory; + return dataDir is null ? null : Path.Combine(dataDir, ManifestFileName); + } + } + + /// + /// Gets the path to the telemetry first-run sentinel file. + /// + /// + /// Returns null if the data directory cannot be determined. + /// + public static string? TelemetrySentinelPath + { + get + { + var dataDir = DataDirectory; + return dataDir is null ? null : Path.Combine(dataDir, TelemetrySentinelFileName); + } + } + + /// + /// Ensures the data directory exists, creating it if necessary. + /// + /// True if the directory exists or was created; false otherwise. + public static bool EnsureDataDirectoryExists() + { + var dataDir = DataDirectory; + if (string.IsNullOrEmpty(dataDir)) + { + return false; + } + + try + { + if (!Directory.Exists(dataDir)) + { + Directory.CreateDirectory(dataDir); + } + return true; + } + catch + { + return false; + } + } + + /// + /// Gets the base directory for dotnetup data storage. + /// + private static string? GetBaseDirectory() + { + // On Windows: use LocalApplicationData (%LOCALAPPDATA%) + // On Unix: use UserProfile (~) - the folder name "dotnetup" will be used (not hidden) + // Unix convention is to use ~/.config for app data, but we use ~/dotnetup for simplicity + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } +} diff --git a/src/Installer/dotnetup/DotnetupSharedManifest.cs b/src/Installer/dotnetup/DotnetupSharedManifest.cs index cade44a02ed4..84af718f69ae 100644 --- a/src/Installer/dotnetup/DotnetupSharedManifest.cs +++ b/src/Installer/dotnetup/DotnetupSharedManifest.cs @@ -1,12 +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.IO; -using System.Linq; using System.Text.Json; -using System.Threading; using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; @@ -41,18 +36,9 @@ private string GetManifestPath() return _customManifestPath; } - // Fall back to environment variable override - var overridePath = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_MANIFEST_PATH"); - if (!string.IsNullOrEmpty(overridePath)) - { - return overridePath; - } - - // Default location - return Path.Combine( - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "dotnetup", - "dotnetup_manifest.json"); + // Use centralized path logic (includes env var override) + return DotnetupPaths.ManifestPath + ?? throw new InvalidOperationException("Could not determine dotnetup data directory."); } private void AssertHasFinalizationMutex() diff --git a/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs index f15b318b991d..88d553d205c1 100644 --- a/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs +++ b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Reflection; -using System.Runtime.InteropServices; - namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// @@ -12,9 +9,6 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// internal static class FirstRunNotice { - private const string SentinelFileName = ".dotnetup-telemetry-notice"; - private const string TelemetryDocsUrl = "https://aka.ms/dotnetup-telemetry"; - /// /// Shows the first-run telemetry notice if this is the first time dotnetup is run /// and telemetry is enabled. Creates a sentinel file to prevent future notices. @@ -28,7 +22,7 @@ public static void ShowIfFirstRun(bool telemetryEnabled) return; } - var sentinelPath = GetSentinelPath(); + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; if (string.IsNullOrEmpty(sentinelPath)) { return; @@ -52,7 +46,7 @@ public static void ShowIfFirstRun(bool telemetryEnabled) /// public static bool IsFirstRun() { - var sentinelPath = GetSentinelPath(); + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; return !string.IsNullOrEmpty(sentinelPath) && !File.Exists(sentinelPath); } @@ -64,43 +58,14 @@ private static void ShowNotice() Console.WriteLine(); } - private static string? GetSentinelPath() - { - try - { - // Use the same location as dotnetup's data directory - var baseDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - - if (string.IsNullOrEmpty(baseDir)) - { - return null; - } - - var dotnetupDir = Path.Combine(baseDir, ".dotnetup"); - return Path.Combine(dotnetupDir, SentinelFileName); - } - catch - { - // If we can't determine the path, skip the notice - return null; - } - } - private static void CreateSentinel(string sentinelPath) { try { - var directory = Path.GetDirectoryName(sentinelPath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } + DotnetupPaths.EnsureDataDirectoryExists(); // Write version info to the sentinel for debugging purposes - var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; - File.WriteAllText(sentinelPath, $"dotnetup telemetry notice shown: {DateTime.UtcNow:O}\nVersion: {version}\n"); + File.WriteAllText(sentinelPath, $"dotnetup telemetry notice shown: {DateTime.UtcNow:O}\nVersion: {BuildInfo.Version}\nCommit: {BuildInfo.CommitSha}\n"); } catch { From 0941794643d2cdc1e0f8b557dc01e025d205648f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 10:30:48 -0800 Subject: [PATCH 22/59] Align with CLI existing code for telemetry - Add llm detection - first run disable env var - stderr over stdout --- .../dotnetup/Telemetry/FirstRunNotice.cs | 30 ++++++++++++++++--- .../Telemetry/TelemetryCommonProperties.cs | 26 +++++++++++++++- src/Installer/dotnetup/dotnetup.csproj | 2 ++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs index 88d553d205c1..ef1d33083454 100644 --- a/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs +++ b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs @@ -9,6 +9,11 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// internal static class FirstRunNotice { + /// + /// Environment variable to suppress the first-run notice (same as .NET SDK). + /// + private const string NoLogoEnvironmentVariable = "DOTNET_NOLOGO"; + /// /// Shows the first-run telemetry notice if this is the first time dotnetup is run /// and telemetry is enabled. Creates a sentinel file to prevent future notices. @@ -22,6 +27,12 @@ public static void ShowIfFirstRun(bool telemetryEnabled) return; } + // Respect DOTNET_NOLOGO to suppress notice (same behavior as .NET SDK) + if (IsNoLogoSet()) + { + return; + } + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; if (string.IsNullOrEmpty(sentinelPath)) { @@ -41,6 +52,16 @@ public static void ShowIfFirstRun(bool telemetryEnabled) CreateSentinel(sentinelPath); } + /// + /// Checks if DOTNET_NOLOGO is set to suppress the first-run notice. + /// + private static bool IsNoLogoSet() + { + var value = Environment.GetEnvironmentVariable(NoLogoEnvironmentVariable); + return string.Equals(value, "1", StringComparison.Ordinal) || + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + /// /// Checks if this is the first run (sentinel doesn't exist). /// @@ -52,10 +73,11 @@ public static bool IsFirstRun() private static void ShowNotice() { - // Keep it brief - link to docs for full details - Console.WriteLine(); - Console.WriteLine(Strings.TelemetryNotice); - Console.WriteLine(); + // Write to stderr, consistent with .NET SDK behavior + // See: https://learn.microsoft.com/dotnet/core/compatibility/sdk/10.0/dotnet-cli-stderr-output + Console.Error.WriteLine(); + Console.Error.WriteLine(Strings.TelemetryNotice); + Console.Error.WriteLine(); } private static void CreateSentinel(string sentinelPath) diff --git a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs index 5e82008978a8..73536ccb9691 100644 --- a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs +++ b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs @@ -16,6 +16,7 @@ internal static class TelemetryCommonProperties { private static readonly Lazy s_deviceId = new(GetDeviceId); private static readonly Lazy s_isCIEnvironment = new(DetectCIEnvironment); + private static readonly Lazy s_llmEnvironment = new(DetectLLMEnvironment); private static readonly Lazy s_isDevBuild = new(DetectDevBuild); /// @@ -28,7 +29,7 @@ internal static class TelemetryCommonProperties /// public static IEnumerable> GetCommonAttributes(string sessionId) { - return new Dictionary + var attributes = new Dictionary { ["session.id"] = sessionId, ["device.id"] = s_deviceId.Value, @@ -39,6 +40,15 @@ public static IEnumerable> GetCommonAttributes(stri ["dotnetup.version"] = GetVersion(), ["dev.build"] = s_isDevBuild.Value }; + + // Add LLM environment if detected (same detection as .NET SDK) + var llmEnv = s_llmEnvironment.Value; + if (!string.IsNullOrEmpty(llmEnv)) + { + attributes["llm.agent"] = llmEnv; + } + + return attributes; } /// @@ -109,6 +119,20 @@ private static bool DetectCIEnvironment() } } + private static string? DetectLLMEnvironment() + { + try + { + // Reuse the SDK's LLM/agent detection + var detector = new LLMEnvironmentDetectorForTelemetry(); + return detector.GetLLMEnvironment(); + } + catch + { + return null; + } + } + private static bool DetectDevBuild() { // Debug builds are always considered dev builds diff --git a/src/Installer/dotnetup/dotnetup.csproj b/src/Installer/dotnetup/dotnetup.csproj index ea5b048b6025..feca371c569f 100644 --- a/src/Installer/dotnetup/dotnetup.csproj +++ b/src/Installer/dotnetup/dotnetup.csproj @@ -29,6 +29,8 @@ + + From a237d4901742867e10450395f6f69d1ba7b2958e Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 10:31:31 -0800 Subject: [PATCH 23/59] Add telemetry notice document Based on https://learn.microsoft.com/en-us/dotnet/core/tools/telemetry?tabs=dotnet10 --- .../dotnetup/docs/telemetry-notice.txt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/Installer/dotnetup/docs/telemetry-notice.txt diff --git a/src/Installer/dotnetup/docs/telemetry-notice.txt b/src/Installer/dotnetup/docs/telemetry-notice.txt new file mode 100644 index 000000000000..31f2e26c676d --- /dev/null +++ b/src/Installer/dotnetup/docs/telemetry-notice.txt @@ -0,0 +1,78 @@ +# dotnetup Telemetry + +dotnetup includes a telemetry feature that collects usage data and sends it to +Microsoft when you use dotnetup commands. The usage data includes exception +information when dotnetup crashes. Telemetry data helps the .NET team understand +how the tools are used so they can be improved. Information on failures helps +the team resolve problems and fix bugs. + +## How to Opt Out + +The dotnetup telemetry feature is enabled by default. To opt out of the telemetry +feature, set the `DOTNET_CLI_TELEMETRY_OPTOUT` environment variable to `1` or `true`. + +To suppress the first-run telemetry notice without disabling telemetry, set the +`DOTNET_NOLOGO` environment variable to `1` or `true`. + +## First-Run Notice + +dotnetup displays the following message on first run: + + dotnetup collects usage data to help improve your experience. You can opt out + by setting the DOTNET_CLI_TELEMETRY_OPTOUT environment variable to '1'. + Learn more: https://aka.ms/dotnetup-telemetry + +## Data Points + +The telemetry feature doesn't collect personal data, such as usernames or email +addresses. It does not scan your code and does not extract project-level data. +The data is sent securely to Microsoft servers using Azure Monitor +(https://azure.microsoft.com/services/monitor/) technology. + +Data collected includes: + +- Timestamp of invocation +- Command invoked (e.g., "install", "update", "list") +- dotnetup version and commit SHA +- Operating system and architecture +- Whether running in a CI environment +- Whether running from an LLM agent (e.g., GitHub Copilot, Claude) +- Exit code / success or failure status +- For failures: error type, error category, and sanitized error details + (no file paths) + +## Crash Exception Telemetry + +If dotnetup crashes, it collects the name of the exception and stack trace of +the dotnetup code and dotnetup invoked code only. The collected data contains the exception type and a +sanitized stack trace that includes method names and line numbers from dotnetup +source code, but NOT file paths. + +Example of collected crash data: + + ErrorType: IOException + Category: Product + SourceLocation: Downloader.DownloadAsync:145@abc123d + ExceptionChain: IOException->SocketException + +## CI and LLM Agent Detection + +dotnetup uses the same CI environment detection and LLM agent detection as the +.NET SDK. For details on which environment variables are checked, see the +.NET CLI telemetry documentation: +https://aka.ms/dotnet-cli-telemetry + +## Privacy + +Protecting your privacy is important to Microsoft. If you suspect the telemetry +is collecting sensitive data or the data is being insecurely or inappropriately +handled, file an issue in the dotnet/sdk repository: +https://github.com/dotnet/sdk/issues + +For more information, see the Microsoft Privacy Statement: +https://www.microsoft.com/privacy/privacystatement + +## See Also + +- .NET CLI telemetry: https://aka.ms/dotnet-cli-telemetry +- dotnetup source code: https://github.com/dotnet/sdk/tree/release/dnup/src/Installer/dotnetup From 60ac35e8eae11dc0e0bdf90b826c5702d68357f5 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 11:46:41 -0800 Subject: [PATCH 24/59] PR Feedback round 1 consolidate error logic code --- .../Internal/DotnetArchiveDownloader.cs | 32 +++++++++- src/Installer/dotnetup/.vscode/launch.json | 63 +++++++++---------- .../dotnetup/DotnetInstallManager.cs | 2 +- .../dotnetup/NonUpdatingProgressTarget.cs | 34 +--------- .../dotnetup/SpectreProgressTarget.cs | 34 +--------- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 38 +---------- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 35 +++++++++++ src/Installer/installer.code-workspace | 3 +- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 25 ++++---- test/dotnetup.Tests/TelemetryTests.cs | 22 ++----- 10 files changed, 120 insertions(+), 168 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index 29144f4da942..c3ab3a67fb5b 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -289,11 +289,41 @@ public void DownloadArchiveWithVerification( } } + /// + /// Known .NET download domains used for telemetry filtering. + /// Only domains in this list are reported; unknown domains are reported as "unknown". + /// This prevents potential PII leakage if a user has configured a custom/private mirror. + /// + /// + /// See: https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/Acquisition/GlobalInstallerResolver.ts + /// + private static readonly string[] KnownDownloadDomains = + [ + "download.visualstudio.microsoft.com", + "builds.dotnet.microsoft.com", + "ci.dot.net", + "dotnetcli.blob.core.windows.net", + "dotnetcli.azureedge.net" // Legacy CDN, may still be referenced + ]; + + /// + /// Gets the domain from a URL for telemetry purposes. + /// Returns "unknown" for unrecognized domains to prevent PII leakage from custom mirrors. + /// private static string GetDomain(string url) { try { - return new Uri(url).Host; + var host = new Uri(url).Host; + // Only report known .NET download domains; filter out custom mirrors + foreach (var knownDomain in KnownDownloadDomains) + { + if (host.Equals(knownDomain, StringComparison.OrdinalIgnoreCase)) + { + return host; + } + } + return "unknown"; } catch { diff --git a/src/Installer/dotnetup/.vscode/launch.json b/src/Installer/dotnetup/.vscode/launch.json index 6da023bb2fad..401b41a481f4 100644 --- a/src/Installer/dotnetup/.vscode/launch.json +++ b/src/Installer/dotnetup/.vscode/launch.json @@ -1,34 +1,33 @@ { - "version": "0.2.0", - "configurations": [ - { - "name": "Launch dotnetup", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceFolder}/../../../artifacts/bin/dotnetup/Debug/net10.0/dotnetup.dll", - "args": "${input:commandLineArgs}", - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "stopAtEntry": false, - "env": { - "DOTNET_CLI_TELEMETRY_OPTOUT": "", - "DOTNETUP_DEV_BUILD": "1" - } - }, - { - "name": ".NET Core Attach", - "type": "coreclr", - "request": "attach", - "requireExactSource": false - } - ], - "inputs": [ - { - "id": "commandLineArgs", - "type": "promptString", - "description": "Command line arguments", - "default": "" - } - ] + "version": "0.2.0", + "configurations": [ + { + "name": "Launch dotnetup", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/../../../artifacts/bin/dotnetup/Debug/net10.0/dotnetup.dll", + "args": "${input:commandLineArgs}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false, + "env": { + "DOTNETUP_DEV_BUILD": "1" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "requireExactSource": false + } + ], + "inputs": [ + { + "id": "commandLineArgs", + "type": "promptString", + "description": "Command line arguments", + "default": "" + } + ] } \ No newline at end of file diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index 15bc0d58352f..d99e512e0a4f 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -125,7 +125,7 @@ private void InstallSDK(DotnetInstallRoot dotnetRoot, ProgressContext progressCo new InstallRequestOptions() ); - var installResult = InstallerOrchestratorSingleton.Instance.Install(request); + InstallResult installResult = InstallerOrchestratorSingleton.Instance.Install(request); if (installResult.Install == null) { throw new Exception($"Failed to install .NET SDK {channnel.Name}"); diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index 56aacb4cdb94..5f6730d85a09 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -82,39 +82,7 @@ public void RecordError(Exception ex) // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - - // Don't pass ex.Message - it can contain PII (paths, user input) - _activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); - _activity.SetTag("error.type", errorInfo.ErrorType); - _activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); - - if (errorInfo.StatusCode.HasValue) - { - _activity.SetTag("error.http_status", errorInfo.StatusCode.Value); - } - - if (errorInfo.HResult.HasValue) - { - _activity.SetTag("error.hresult", errorInfo.HResult.Value); - } - - if (errorInfo.Details is not null) - { - _activity.SetTag("error.details", errorInfo.Details); - } - - if (errorInfo.SourceLocation is not null) - { - _activity.SetTag("error.source_location", errorInfo.SourceLocation); - } - - if (errorInfo.ExceptionChain is not null) - { - _activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); - } - - // NOTE: We intentionally do NOT call _activity.RecordException(ex) - // because exception messages/stacks can contain PII + ErrorCodeMapper.ApplyErrorTags(_activity, errorInfo); } public void Complete() diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index e4c675769963..dfef285ae8fa 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -96,39 +96,7 @@ public void RecordError(Exception ex) // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - - // Don't pass ex.Message - it can contain PII (paths, user input) - _activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); - _activity.SetTag("error.type", errorInfo.ErrorType); - _activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); - - if (errorInfo.StatusCode.HasValue) - { - _activity.SetTag("error.http_status", errorInfo.StatusCode.Value); - } - - if (errorInfo.HResult.HasValue) - { - _activity.SetTag("error.hresult", errorInfo.HResult.Value); - } - - if (errorInfo.Details is not null) - { - _activity.SetTag("error.details", errorInfo.Details); - } - - if (errorInfo.SourceLocation is not null) - { - _activity.SetTag("error.source_location", errorInfo.SourceLocation); - } - - if (errorInfo.ExceptionChain is not null) - { - _activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); - } - - // NOTE: We intentionally do NOT call _activity.RecordException(ex) - // because exception messages/stacks can contain PII + ErrorCodeMapper.ApplyErrorTags(_activity, errorInfo); } public void Complete() diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index 35343fb34a08..1e9974a51106 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -145,43 +145,7 @@ public void RecordException(Activity? activity, Exception ex, string? errorCode } var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - - // Don't pass ex.Message to SetStatus - it can contain PII (paths, user input, etc.) - // Use the error type as the status description instead - activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); - activity.SetTag("error.type", errorInfo.ErrorType); - activity.SetTag("error.code", errorCode ?? errorInfo.ErrorType); - activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); - - if (errorInfo.StatusCode.HasValue) - { - activity.SetTag("error.http_status", errorInfo.StatusCode.Value); - } - - if (errorInfo.HResult.HasValue) - { - activity.SetTag("error.hresult", errorInfo.HResult.Value); - } - - if (errorInfo.Details is not null) - { - activity.SetTag("error.details", errorInfo.Details); - } - - if (errorInfo.SourceLocation is not null) - { - activity.SetTag("error.source_location", errorInfo.SourceLocation); - } - - if (errorInfo.ExceptionChain is not null) - { - activity.SetTag("error.exception_chain", errorInfo.ExceptionChain); - } - - // NOTE: We intentionally do NOT call activity.RecordException(ex) because: - // 1. Exception messages can contain PII (file paths, user input, etc.) - // 2. Stack traces contain internal implementation details - // 3. We capture all relevant info via sanitized tags above + ErrorCodeMapper.ApplyErrorTags(activity, errorInfo, errorCode); } /// diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index f8651a76fd8e..2be57a5b320e 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -53,6 +53,41 @@ public sealed record ExceptionErrorInfo( /// public static class ErrorCodeMapper { + /// + /// Applies error info tags to an activity. This centralizes the tag-setting logic + /// to avoid code duplication across progress targets and telemetry classes. + /// + /// The activity to tag (can be null). + /// The error info to apply. + /// Optional error code override. + public static void ApplyErrorTags(Activity? activity, ExceptionErrorInfo errorInfo, string? errorCode = null) + { + if (activity is null) return; + + activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); + activity.SetTag("error.type", errorInfo.ErrorType); + if (errorCode is not null) + { + activity.SetTag("error.code", errorCode); + } + activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); + + // Use pattern matching to set optional tags only if they have values + if (errorInfo is { StatusCode: { } statusCode }) + activity.SetTag("error.http_status", statusCode); + if (errorInfo is { HResult: { } hResult }) + activity.SetTag("error.hresult", hResult); + if (errorInfo is { Details: { } details }) + activity.SetTag("error.details", details); + if (errorInfo is { SourceLocation: { } sourceLocation }) + activity.SetTag("error.source_location", sourceLocation); + if (errorInfo is { ExceptionChain: { } exceptionChain }) + activity.SetTag("error.exception_chain", exceptionChain); + + // NOTE: We intentionally do NOT call activity.RecordException(ex) + // because exception messages/stacks can contain PII + } + /// /// Extracts error info from an exception. /// diff --git a/src/Installer/installer.code-workspace b/src/Installer/installer.code-workspace index 1ecbdccc0bd4..935ffc6c984b 100644 --- a/src/Installer/installer.code-workspace +++ b/src/Installer/installer.code-workspace @@ -36,8 +36,7 @@ "moduleLoad": false }, "env": { - "DOTNETUP_DEV_BUILD": "1", - "DOTNET_CLI_TELEMETRY_OPTOUT": "0" + "DOTNETUP_DEV_BUILD": "1" } }, { diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index bb807170628b..0ed353375099 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -22,10 +22,10 @@ public void GetErrorInfo_IOException_DiskFull_MapsCorrectly() Assert.Equal("DiskFull", info.ErrorType); Assert.Equal(unchecked((int)0x80070070), info.HResult); - // On Windows, we get the readable message from Win32Exception + // Details contain the win32 error code for PII safety if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.Contains("not enough space", info.Details, StringComparison.OrdinalIgnoreCase); + Assert.Equal("win32_error_112", info.Details); // ERROR_DISK_FULL = 112 } else { @@ -42,11 +42,10 @@ public void GetErrorInfo_IOException_SharingViolation_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("SharingViolation", info.ErrorType); - // On Windows, we get the readable message + // Details contain the win32 error code if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.NotNull(info.Details); - Assert.NotEmpty(info.Details!); + Assert.Equal("win32_error_32", info.Details); // ERROR_SHARING_VIOLATION = 32 } else { @@ -62,10 +61,10 @@ public void GetErrorInfo_IOException_PathTooLong_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("PathTooLong", info.ErrorType); - // On Windows, we get the readable message + // Details contain the win32 error code for PII safety if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.Contains("too long", info.Details, StringComparison.OrdinalIgnoreCase); + Assert.Equal("win32_error_206", info.Details); // ERROR_FILENAME_EXCED_RANGE = 206 } else { @@ -196,10 +195,10 @@ public void GetErrorInfo_HResultAndDetails_ForDiskFullException() Assert.Equal("DiskFull", info.ErrorType); Assert.Equal(unchecked((int)0x80070070), info.HResult); - // On Windows, we get the readable message from Win32Exception + // Details contain the win32 error code for PII safety if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.Contains("not enough space", info.Details, StringComparison.OrdinalIgnoreCase); + Assert.Equal("win32_error_112", info.Details); // ERROR_DISK_FULL = 112 } else { @@ -246,10 +245,10 @@ public void GetErrorInfo_NetworkPathNotFound_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("NetworkPathNotFound", info.ErrorType); - // On Windows, we get the readable message + // Details contain the win32 error code for PII safety if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.Contains("network path", info.Details, StringComparison.OrdinalIgnoreCase); + Assert.Equal("win32_error_53", info.Details); // ERROR_BAD_NETPATH = 53 } else { @@ -267,10 +266,10 @@ public void GetErrorInfo_AlreadyExists_MapsCorrectly() Assert.Equal("AlreadyExists", info.ErrorType); Assert.Equal(unchecked((int)0x800700B7), info.HResult); - // On Windows, we get the readable message + // Details contain the win32 error code for PII safety if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.Contains("already exists", info.Details, StringComparison.OrdinalIgnoreCase); + Assert.Equal("win32_error_183", info.Details); // ERROR_ALREADY_EXISTS = 183 } else { diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index b006a1a761a7..45a34d08dc7a 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -315,10 +315,9 @@ public class FirstRunNoticeTests public void IsFirstRun_ReturnsTrueWhenSentinelDoesNotExist() { // Clean up any existing sentinel for this test - var sentinelDir = GetSentinelDirectory(); - var sentinelPath = Path.Combine(sentinelDir, ".dotnetup-telemetry-notice"); + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; - if (File.Exists(sentinelPath)) + if (!string.IsNullOrEmpty(sentinelPath) && File.Exists(sentinelPath)) { File.Delete(sentinelPath); } @@ -330,8 +329,8 @@ public void IsFirstRun_ReturnsTrueWhenSentinelDoesNotExist() public void ShowIfFirstRun_CreatesSentinelFile() { // Clean up any existing sentinel for this test - var sentinelDir = GetSentinelDirectory(); - var sentinelPath = Path.Combine(sentinelDir, ".dotnetup-telemetry-notice"); + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; + Assert.NotNull(sentinelPath); if (File.Exists(sentinelPath)) { @@ -352,8 +351,8 @@ public void ShowIfFirstRun_CreatesSentinelFile() public void ShowIfFirstRun_DoesNotCreateSentinel_WhenTelemetryDisabled() { // Clean up any existing sentinel for this test - var sentinelDir = GetSentinelDirectory(); - var sentinelPath = Path.Combine(sentinelDir, ".dotnetup-telemetry-notice"); + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; + Assert.NotNull(sentinelPath); if (File.Exists(sentinelPath)) { @@ -366,13 +365,4 @@ public void ShowIfFirstRun_DoesNotCreateSentinel_WhenTelemetryDisabled() // Sentinel should NOT be created (user has opted out) Assert.False(File.Exists(sentinelPath)); } - - private static string GetSentinelDirectory() - { - var baseDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - - return Path.Combine(baseDir, ".dotnetup"); - } } From 765cc87ea0c55ef64f7ca738144382b295a8118b Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 13:16:27 -0800 Subject: [PATCH 25/59] Demo project for libraries to attach to dotnetup --- .../Internal/InstallationActivitySource.cs | 42 +++++------- .../dotnetup/DotnetupSharedManifest.cs | 5 ++ test/dotnetup.Tests/TelemetryTests.cs | 64 +++++++++++++++++++ .../Directory.Build.props | 27 ++++++++ .../TelemetryIntegrationDemo/Program.cs | 50 +++++++++++++++ .../TelemetryIntegrationDemo/README.md | 34 ++++++++++ .../TelemetryIntegrationDemo.csproj | 16 +++++ test/dotnetup.Tests/dotnetup.Tests.csproj | 7 +- 8 files changed, 219 insertions(+), 26 deletions(-) create mode 100644 test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/Directory.Build.props create mode 100644 test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/Program.cs create mode 100644 test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/README.md create mode 100644 test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/TelemetryIntegrationDemo.csproj diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs index 2e2615ef4f54..cf44336939de 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs @@ -13,6 +13,19 @@ namespace Microsoft.Dotnet.Installation.Internal; /// /// /// +/// Important: If you use this library and collect telemetry, you are responsible +/// for complying with the .NET SDK telemetry policy. This includes: +/// +/// +/// Displaying a first-run notice to users explaining what data is collected +/// Honoring the DOTNET_CLI_TELEMETRY_OPTOUT environment variable +/// Providing documentation about your telemetry practices +/// +/// +/// See the .NET SDK telemetry documentation for guidance: +/// https://learn.microsoft.com/dotnet/core/tools/telemetry +/// +/// /// Library consumers can hook into installation telemetry by subscribing to this ActivitySource. /// The following activities are emitted: /// @@ -21,34 +34,13 @@ namespace Microsoft.Dotnet.Installation.Internal; /// extractArchive extraction. Tags: download.version /// /// -/// Example usage: -/// -/// -/// using var listener = new ActivityListener -/// { -/// ShouldListenTo = source => source.Name == "Microsoft.Dotnet.Installation", -/// Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded, -/// ActivityStarted = activity => -/// { -/// // Add custom tags (e.g., your tool's name) -/// activity.SetTag("caller", "my-custom-tool"); -/// }, -/// ActivityStopped = activity => -/// { -/// // Export to your telemetry system -/// Console.WriteLine($"{activity.DisplayName}: {activity.Duration.TotalMilliseconds}ms"); -/// } -/// }; -/// ActivitySource.AddActivityListener(listener); -/// -/// // Now use the library - activities will be captured -/// var installer = InstallerFactory.Create(progressTarget); -/// installer.Install(root, InstallComponent.Sdk, version); -/// -/// /// When activities originate from dotnetup CLI, they include the tag caller=dotnetup. /// Library consumers can use this to distinguish CLI-originated vs direct library calls. /// +/// +/// For a working example of how to integrate with this ActivitySource, see the +/// TelemetryIntegrationDemo project in test/dotnetup.Tests/TestAssets/. +/// /// internal static class InstallationActivitySource { diff --git a/src/Installer/dotnetup/DotnetupSharedManifest.cs b/src/Installer/dotnetup/DotnetupSharedManifest.cs index 84af718f69ae..cda8ce72ab1c 100644 --- a/src/Installer/dotnetup/DotnetupSharedManifest.cs +++ b/src/Installer/dotnetup/DotnetupSharedManifest.cs @@ -62,6 +62,11 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v { json = File.ReadAllText(ManifestPath); } + catch (FileNotFoundException) + { + // Manifest doesn't exist yet - return empty list + return []; + } catch (IOException ex) { throw new DotnetInstallException( diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 45a34d08dc7a..317fc88c72a1 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Runtime.InteropServices; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Xunit; @@ -366,3 +368,65 @@ public void ShowIfFirstRun_DoesNotCreateSentinel_WhenTelemetryDisabled() Assert.False(File.Exists(sentinelPath)); } } + +/// +/// Tests for ActivitySource integration - verifies that library consumers can hook into telemetry +/// using the pattern demonstrated in TelemetryIntegrationDemo. +/// +public class ActivitySourceIntegrationTests +{ + private const string InstallationActivitySourceName = "Microsoft.Dotnet.Installation"; + + [Fact] + public void ActivityListener_CanCaptureActivities_FromInstallationActivitySource() + { + // Arrange - set up listener like the demo shows + var capturedActivities = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == InstallationActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => capturedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + + // Act - create an activity using the library's ActivitySource + using (var activity = InstallationActivitySource.ActivitySource.StartActivity("test-activity")) + { + activity?.SetTag("test.key", "test-value"); + } + + // Assert + Assert.Single(capturedActivities); + Assert.Equal("test-activity", capturedActivities[0].DisplayName); + Assert.Contains(capturedActivities[0].Tags, t => t.Key == "test.key" && t.Value == "test-value"); + } + + [Fact] + public void NonUpdatingProgressTarget_SetsCallerTag_ToDotnetup() + { + // Arrange - set up listener to capture activities + var capturedActivities = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == InstallationActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => capturedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + + // Act - use NonUpdatingProgressTarget which sets caller=dotnetup + var progressTarget = new NonUpdatingProgressTarget(); + using (var reporter = progressTarget.CreateProgressReporter()) + { + var task = reporter.AddTask("download", "Test download task", 100); + task.Value = 100; + task.Complete(); + } + + // Assert - verify caller tag is set to dotnetup + Assert.Single(capturedActivities); + var callerTag = capturedActivities[0].Tags.FirstOrDefault(t => t.Key == "caller"); + Assert.Equal("dotnetup", callerTag.Value); + } +} diff --git a/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/Directory.Build.props b/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/Directory.Build.props new file mode 100644 index 000000000000..c87530dee005 --- /dev/null +++ b/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/Directory.Build.props @@ -0,0 +1,27 @@ + + + + + + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/Program.cs b/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/Program.cs new file mode 100644 index 000000000000..35ddd323347f --- /dev/null +++ b/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/Program.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace TelemetryIntegrationDemo; + +/// +/// Minimal example showing how to capture telemetry activities from Microsoft.Dotnet.Installation. +/// +/// NOTE: If you collect telemetry in production, you are responsible for: +/// - Displaying a first-run notice to users +/// - Honoring DOTNET_CLI_TELEMETRY_OPTOUT +/// - Providing telemetry documentation +/// See: https://learn.microsoft.com/dotnet/core/tools/telemetry +/// +public static class Program +{ + private const string InstallationActivitySourceName = "Microsoft.Dotnet.Installation"; + + public static void Main(string[] args) + { + // Set up ActivityListener to capture activities from the library + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == InstallationActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => + { + // Add your tool's identifier + activity.SetTag("caller", "MyTool"); + Console.WriteLine($"Activity started: {activity.DisplayName}"); + }, + ActivityStopped = activity => + { + Console.WriteLine($"Activity stopped: {activity.DisplayName}, Duration: {activity.Duration.TotalMilliseconds:F1}ms"); + foreach (var tag in activity.Tags) + { + Console.WriteLine($" {tag.Key}: {tag.Value}"); + } + } + }; + ActivitySource.AddActivityListener(listener); + + Console.WriteLine("Listener attached. Use the library to see activities captured."); + + // Example: Use InstallerFactory.Create() and perform installations + // Activities will be automatically captured by the listener above + } +} diff --git a/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/README.md b/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/README.md new file mode 100644 index 000000000000..1abf95f95609 --- /dev/null +++ b/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/README.md @@ -0,0 +1,34 @@ +# Telemetry Integration Demo + +Minimal example showing how to capture telemetry activities from `Microsoft.Dotnet.Installation`. + +## Usage + +```csharp +using var listener = new ActivityListener +{ + ShouldListenTo = source => source.Name == "Microsoft.Dotnet.Installation", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => activity.SetTag("caller", "MyTool"), + ActivityStopped = activity => Console.WriteLine($"{activity.DisplayName}: {activity.Duration.TotalMilliseconds}ms") +}; +ActivitySource.AddActivityListener(listener); + +// Use the library - activities are captured automatically +``` + +## Available Activities + +| Activity | Tags | +|----------|------| +| `download` | `download.version`, `download.url`, `download.bytes`, `download.from_cache` | +| `extract` | `download.version` | + +## Production Requirements + +If collecting telemetry, you must: +- Display a first-run notice +- Honor `DOTNET_CLI_TELEMETRY_OPTOUT` +- Provide documentation + +See: https://learn.microsoft.com/dotnet/core/tools/telemetry diff --git a/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/TelemetryIntegrationDemo.csproj b/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/TelemetryIntegrationDemo.csproj new file mode 100644 index 000000000000..c9a08a0efb56 --- /dev/null +++ b/test/dotnetup.Tests/TestAssets/TelemetryIntegrationDemo/TelemetryIntegrationDemo.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + TelemetryIntegrationDemo + false + + + + + + + diff --git a/test/dotnetup.Tests/dotnetup.Tests.csproj b/test/dotnetup.Tests/dotnetup.Tests.csproj index f81b05abfbe9..3eb421bd02f0 100644 --- a/test/dotnetup.Tests/dotnetup.Tests.csproj +++ b/test/dotnetup.Tests/dotnetup.Tests.csproj @@ -1,4 +1,4 @@ - + enable @@ -8,6 +8,11 @@ Tests\$(MSBuildProjectName) + + + + + From 7d825d664026dcdddf8857187db2ef8b60a56fce Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 13:20:25 -0800 Subject: [PATCH 26/59] url sanitization --- .../Internal/DotnetArchiveDownloader.cs | 46 +-------------- .../dotnetup/NonUpdatingProgressTarget.cs | 14 ++++- .../dotnetup/SpectreProgressTarget.cs | 14 ++++- .../dotnetup/Telemetry/UrlSanitizer.cs | 59 +++++++++++++++++++ test/dotnetup.Tests/TelemetryTests.cs | 48 +++++++++++++++ 5 files changed, 135 insertions(+), 46 deletions(-) create mode 100644 src/Installer/dotnetup/Telemetry/UrlSanitizer.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index c3ab3a67fb5b..b92cf0550c3b 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -239,8 +239,8 @@ public void DownloadArchiveWithVerification( component: installRequest.Component.ToString()); } - // Set download URL domain for telemetry - telemetryTask?.SetTag("download.url_domain", GetDomain(downloadUrl)); + // Set download URL for telemetry (caller is responsible for sanitization) + telemetryTask?.SetTag("download.url", downloadUrl); // Check the cache first string? cachedFilePath = _downloadCache.GetCachedFilePath(downloadUrl); @@ -289,48 +289,6 @@ public void DownloadArchiveWithVerification( } } - /// - /// Known .NET download domains used for telemetry filtering. - /// Only domains in this list are reported; unknown domains are reported as "unknown". - /// This prevents potential PII leakage if a user has configured a custom/private mirror. - /// - /// - /// See: https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/Acquisition/GlobalInstallerResolver.ts - /// - private static readonly string[] KnownDownloadDomains = - [ - "download.visualstudio.microsoft.com", - "builds.dotnet.microsoft.com", - "ci.dot.net", - "dotnetcli.blob.core.windows.net", - "dotnetcli.azureedge.net" // Legacy CDN, may still be referenced - ]; - - /// - /// Gets the domain from a URL for telemetry purposes. - /// Returns "unknown" for unrecognized domains to prevent PII leakage from custom mirrors. - /// - private static string GetDomain(string url) - { - try - { - var host = new Uri(url).Host; - // Only report known .NET download domains; filter out custom mirrors - foreach (var knownDomain in KnownDownloadDomains) - { - if (host.Equals(knownDomain, StringComparison.OrdinalIgnoreCase)) - { - return host; - } - } - return "unknown"; - } - catch - { - return "unknown"; - } - } - /// /// Computes the SHA512 hash of a file. diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index 5f6730d85a09..f822ca4b9865 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -74,7 +74,19 @@ public double Value public string Description { get; set; } public double MaxValue { get; set; } - public void SetTag(string key, object? value) => _activity?.SetTag(key, value); + public void SetTag(string key, object? value) + { + if (_activity == null) return; + + // Sanitize URL tags to prevent PII leakage + if (key == "download.url" && value is string url) + { + _activity.SetTag("download.url_domain", UrlSanitizer.SanitizeDomain(url)); + return; + } + + _activity.SetTag(key, value); + } public void RecordError(Exception ex) { diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index dfef285ae8fa..ea635d9a6b51 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -88,7 +88,19 @@ public double MaxValue set => _task.MaxValue = value; } - public void SetTag(string key, object? value) => _activity?.SetTag(key, value); + public void SetTag(string key, object? value) + { + if (_activity == null) return; + + // Sanitize URL tags to prevent PII leakage + if (key == "download.url" && value is string url) + { + _activity.SetTag("download.url_domain", UrlSanitizer.SanitizeDomain(url)); + return; + } + + _activity.SetTag(key, value); + } public void RecordError(Exception ex) { diff --git a/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs b/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs new file mode 100644 index 000000000000..7b121d9a225a --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Sanitizes URLs for telemetry to prevent PII leakage. +/// Only known safe domains are reported; unknown domains are replaced with "unknown". +/// +public static class UrlSanitizer +{ + /// + /// Known .NET download domains that are safe to report in telemetry. + /// Unknown domains are reported as "unknown" to prevent PII leakage + /// from custom/private mirrors. + /// + /// + /// See: https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/Acquisition/GlobalInstallerResolver.ts + /// + public static readonly IReadOnlyList KnownDownloadDomains = + [ + "download.visualstudio.microsoft.com", + "builds.dotnet.microsoft.com", + "ci.dot.net", + "dotnetcli.blob.core.windows.net", + "dotnetcli.azureedge.net" // Legacy CDN, may still be referenced + ]; + + /// + /// Extracts and sanitizes the domain from a URL for telemetry purposes. + /// Returns "unknown" for unrecognized domains to prevent PII leakage from custom mirrors. + /// + /// The URL to extract the domain from. + /// The domain if known, or "unknown" for unrecognized/private domains. + public static string SanitizeDomain(string? url) + { + if (string.IsNullOrEmpty(url)) + { + return "unknown"; + } + + try + { + var host = new Uri(url).Host; + foreach (var knownDomain in KnownDownloadDomains) + { + if (host.Equals(knownDomain, StringComparison.OrdinalIgnoreCase)) + { + return host; + } + } + return "unknown"; + } + catch + { + return "unknown"; + } + } +} diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 317fc88c72a1..5b64114c2b7c 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -213,6 +213,54 @@ public void Sanitize_ValidWildcards_PassThrough(string input) } } +public class UrlSanitizerTests +{ + [Theory] + [InlineData("https://download.visualstudio.microsoft.com/download/pr/123/file.zip", "download.visualstudio.microsoft.com")] + [InlineData("https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.100/dotnet-sdk.zip", "builds.dotnet.microsoft.com")] + [InlineData("https://ci.dot.net/job/123/artifact.zip", "ci.dot.net")] + [InlineData("https://dotnetcli.blob.core.windows.net/dotnet/Sdk/9.0.100/dotnet-sdk.zip", "dotnetcli.blob.core.windows.net")] + [InlineData("https://dotnetcli.azureedge.net/dotnet/Sdk/9.0.100/dotnet-sdk.zip", "dotnetcli.azureedge.net")] + public void SanitizeDomain_KnownDomains_ReturnsDomain(string url, string expectedDomain) + { + var result = UrlSanitizer.SanitizeDomain(url); + + Assert.Equal(expectedDomain, result); + } + + [Theory] + [InlineData("https://my-private-mirror.company.com/dotnet/sdk.zip")] + [InlineData("https://internal.corp.net/artifacts/dotnet-sdk.zip")] + [InlineData("https://192.168.1.100/dotnet/sdk.zip")] + [InlineData("file:///C:/Users/someone/Downloads/sdk.zip")] + public void SanitizeDomain_UnknownDomains_ReturnsUnknown(string url) + { + var result = UrlSanitizer.SanitizeDomain(url); + + Assert.Equal("unknown", result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-url")] + [InlineData("ftp://")] + public void SanitizeDomain_InvalidUrls_ReturnsUnknown(string? url) + { + var result = UrlSanitizer.SanitizeDomain(url); + + Assert.Equal("unknown", result); + } + + [Fact] + public void KnownDownloadDomains_ContainsExpectedDomains() + { + Assert.Contains("download.visualstudio.microsoft.com", UrlSanitizer.KnownDownloadDomains); + Assert.Contains("builds.dotnet.microsoft.com", UrlSanitizer.KnownDownloadDomains); + Assert.Contains("ci.dot.net", UrlSanitizer.KnownDownloadDomains); + } +} + public class DotnetupTelemetryTests { [Fact] From 3acca2f3d54a1e1542f58ea0dad6da30f7151570 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 13:45:52 -0800 Subject: [PATCH 27/59] Don't show entire stack trace + better version err --- .../Internal/ChannelVersionResolver.cs | 61 +++++++++++++++++++ src/Installer/dotnetup/CommandBase.cs | 12 ++++ .../InstallerOrchestratorSingleton.cs | 16 ++++- .../ChannelVersionResolverTests.cs | 33 ++++++++++ 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs index 76981ce3632f..156d1f0856a8 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs @@ -35,6 +35,12 @@ internal class ChannelVersionResolver /// public static readonly IReadOnlyList KnownChannelKeywords = [LatestChannel, PreviewChannel, LtsChannel, StsChannel]; + /// + /// Maximum reasonable major version number. .NET versions are currently single-digit; + /// anything above 99 is clearly invalid input (e.g., typos, random numbers). + /// + internal const int MaxReasonableMajorVersion = 99; + private ReleaseManifest _releaseManifest = new(); public ChannelVersionResolver() @@ -77,6 +83,61 @@ static IEnumerable GetChannelsForProduct(Product product) return GetLatestVersionForChannel(installRequest.Channel, installRequest.Component); } + /// + /// Checks if a channel string looks like a valid .NET version/channel format. + /// This is a preliminary validation before attempting resolution. + /// + /// The channel string to validate + /// True if the format appears valid, false if clearly invalid + public static bool IsValidChannelFormat(string channel) + { + if (string.IsNullOrWhiteSpace(channel)) + { + return false; + } + + // Known keywords are always valid + if (KnownChannelKeywords.Any(k => string.Equals(k, channel, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + // Try to parse as a version-like string + var parts = channel.Split('.'); + if (parts.Length == 0 || parts.Length > 4) + { + return false; + } + + // First part must be a valid major version + if (!int.TryParse(parts[0], out var major) || major < 0 || major > MaxReasonableMajorVersion) + { + return false; + } + + // If there are more parts, validate them + if (parts.Length >= 2) + { + if (!int.TryParse(parts[1], out var minor) || minor < 0) + { + return false; + } + } + + if (parts.Length >= 3) + { + var patch = parts[2]; + // Allow feature band pattern (e.g., "1xx", "100") or patch number + var normalizedPatch = patch.Replace("x", "").Replace("X", ""); + if (normalizedPatch.Length > 0 && !int.TryParse(normalizedPatch, out _)) + { + return false; + } + } + + return true; + } + /// /// Parses a version channel string into its components. /// diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index 8dfce8a0ef6a..427fb312d4f8 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -6,7 +6,9 @@ using System.CommandLine; using System.Diagnostics; using System.Text; +using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -45,6 +47,16 @@ public int Execute() return exitCode; } + catch (DotnetInstallException ex) + { + // Known installation errors - print a clean user-friendly message + stopwatch.Stop(); + _commandActivity?.SetTag("duration_ms", stopwatch.Elapsed.TotalMilliseconds); + _commandActivity?.SetTag("exit.code", 1); + DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); + AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]"); + return 1; + } catch (Exception ex) { stopwatch.Stop(); diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index 86c1ce83059e..a956f8e32867 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -32,17 +32,27 @@ private InstallerOrchestratorSingleton() // Returns InstallResult with Install=null on failure, or Install=DotnetInstall on success public InstallResult Install(DotnetInstallRequest installRequest, bool noProgress = false) { + // Validate channel format before attempting resolution + if (!ChannelVersionResolver.IsValidChannelFormat(installRequest.Channel.Name)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InvalidChannel, + $"'{installRequest.Channel.Name}' is not a valid .NET version or channel. " + + $"Use a version like '9.0', '9.0.100', or a channel keyword: {string.Join(", ", ChannelVersionResolver.KnownChannelKeywords)}.", + version: null, // Don't include user input in telemetry + component: installRequest.Component.ToString()); + } + // Map InstallRequest to DotnetInstallObject by converting channel to fully specified version ReleaseManifest releaseManifest = new(); ReleaseVersion? versionToInstall = new ChannelVersionResolver(releaseManifest).Resolve(installRequest); if (versionToInstall == null) { - // Don't pass raw user input to exception - it goes to telemetry - // Just report that the version couldn't be resolved + // Channel format was valid, but the version doesn't exist throw new DotnetInstallException( DotnetInstallErrorCode.VersionNotFound, - $"Could not resolve version for the specified channel. The channel may be invalid or unsupported.", + $"Could not find .NET version '{installRequest.Channel.Name}'. The version may not exist or may not be supported.", version: null, // Don't include user input in telemetry component: installRequest.Component.ToString()); } diff --git a/test/dotnetup.Tests/ChannelVersionResolverTests.cs b/test/dotnetup.Tests/ChannelVersionResolverTests.cs index ba2837772983..763afa94c244 100644 --- a/test/dotnetup.Tests/ChannelVersionResolverTests.cs +++ b/test/dotnetup.Tests/ChannelVersionResolverTests.cs @@ -98,5 +98,38 @@ public void GetLatestVersionForChannel_Preview_ReturnsLatestPreviewVersion() $"Version {version} should be a preview/rc/beta/alpha version" ); } + + [Theory] + [InlineData("latest", true)] + [InlineData("preview", true)] + [InlineData("lts", true)] + [InlineData("sts", true)] + [InlineData("LTS", true)] // Case insensitive + [InlineData("9", true)] + [InlineData("9.0", true)] + [InlineData("9.0.100", true)] + [InlineData("9.0.1xx", true)] + [InlineData("10", true)] + [InlineData("99", true)] // Max reasonable major + [InlineData("99.0.100", true)] + public void IsValidChannelFormat_ValidInputs_ReturnsTrue(string channel, bool expected) + { + Assert.Equal(expected, ChannelVersionResolver.IsValidChannelFormat(channel)); + } + + [Theory] + [InlineData("939393939", false)] // Way too large major version + [InlineData("100", false)] // Just over max reasonable + [InlineData("999999", false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("abc", false)] + [InlineData("invalid", false)] + [InlineData("-1", false)] // Negative + [InlineData("9.-1.100", false)] // Negative minor + public void IsValidChannelFormat_InvalidInputs_ReturnsFalse(string channel, bool expected) + { + Assert.Equal(expected, ChannelVersionResolver.IsValidChannelFormat(channel)); + } } } From 378bb8eae7c7a193b20db0cc7eaccece732bacb5 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 2 Feb 2026 13:49:10 -0800 Subject: [PATCH 28/59] Don't fail with lock error Read more at https://github.com/dotnet/sdk/issues/52789 --- .../dotnetup/InstallerOrchestratorSingleton.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index a956f8e32867..72e53256bad9 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -70,9 +72,11 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres { if (!finalizeLock.HasHandle) { - throw new DotnetInstallException( - DotnetInstallErrorCode.InstallationLocked, - $"Could not acquire installation lock. Another dotnetup or installation process may be running."); + // Log for telemetry but don't block - we may risk clobber but prefer UX over safety here + // See: https://github.com/dotnet/sdk/issues/52789 for tracking + Activity.Current?.SetTag("install.mutex_lock_failed", true); + Activity.Current?.SetTag("install.mutex_lock_phase", "pre_check"); + Console.Error.WriteLine("Warning: Could not acquire installation lock. Another dotnetup process may be running. Proceeding anyway."); } if (InstallAlreadyExists(install, customManifestPath)) { @@ -91,9 +95,11 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres { if (!finalizeLock.HasHandle) { - throw new DotnetInstallException( - DotnetInstallErrorCode.InstallationLocked, - $"Could not acquire installation lock. Another dotnetup or installation process may be running."); + // Log for telemetry but don't block - we may risk clobber but prefer UX over safety here + // See: https://github.com/dotnet/sdk/issues/52789 for tracking + Activity.Current?.SetTag("install.mutex_lock_failed", true); + Activity.Current?.SetTag("install.mutex_lock_phase", "commit"); + Console.Error.WriteLine("Warning: Could not acquire installation lock. Another dotnetup process may be running. Proceeding anyway."); } if (InstallAlreadyExists(install, customManifestPath)) { From 80da3912b10c764dcff0f4dc5cac7228d9ebc5e7 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Tue, 3 Feb 2026 11:27:17 -0800 Subject: [PATCH 29/59] --format json for info but still have custom output option --- .../dotnetup/Commands/Info/InfoCommand.cs | 37 ++++++++++++++++--- .../Commands/Info/InfoCommandParser.cs | 4 +- test/dotnetup.Tests/InfoCommandTests.cs | 8 ++-- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/Installer/dotnetup/Commands/Info/InfoCommand.cs b/src/Installer/dotnetup/Commands/Info/InfoCommand.cs index dc8239f09148..73ba8b0af906 100644 --- a/src/Installer/dotnetup/Commands/Info/InfoCommand.cs +++ b/src/Installer/dotnetup/Commands/Info/InfoCommand.cs @@ -6,18 +6,45 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.DotNet.Tools.Bootstrapper.Commands.List; -using Spectre.Console; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Info; internal class InfoCommand : CommandBase { - public static int Execute(OutputFormat format, bool noList = false, TextWriter? output = null) + private readonly OutputFormat _format; + private readonly bool _noList; + private readonly TextWriter _output; + + /// + /// Constructor for use with the command-line parser. + /// + public InfoCommand(ParseResult parseResult) : base(parseResult) + { + _format = parseResult.GetValue(InfoCommandParser.FormatOption); + _noList = parseResult.GetValue(InfoCommandParser.NoListOption); + _output = Console.Out; + } + + /// + /// Constructor for testing with explicit parameters. + /// + public InfoCommand(ParseResult parseResult, OutputFormat format, bool noList, TextWriter output) : base(parseResult) { - _jsonOutput = jsonOutput; + _format = format; _noList = noList; - _output = output ?? Console.Out; + _output = output; + } + + /// + /// Static helper for tests to execute the command without needing a ParseResult. + /// + public static int Execute(OutputFormat format, bool noList, TextWriter output) + { + var parseResult = Parser.Parse(new[] { "--info" }); + var command = new InfoCommand(parseResult, format, noList, output); + return command.Execute(); } protected override string GetCommandName() => "info"; @@ -33,7 +60,7 @@ protected override int ExecuteCore() installations = InstallationLister.GetInstallations(verify: true); } - if (format == OutputFormat.Json) + if (_format == OutputFormat.Json) { PrintJsonInfo(_output, info, installations); } diff --git a/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs b/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs index 35cb5bc5bd17..0b206606ba08 100644 --- a/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs @@ -31,9 +31,7 @@ private static Command ConstructCommand() command.SetAction(parseResult => { - var format = parseResult.GetValue(FormatOption); - var noList = parseResult.GetValue(NoListOption); - var infoCommand = new Info.InfoCommand(format, noList); + var infoCommand = new Info.InfoCommand(parseResult); return infoCommand.Execute(); }); diff --git a/test/dotnetup.Tests/InfoCommandTests.cs b/test/dotnetup.Tests/InfoCommandTests.cs index 6434b70dd467..2bdd6fc55869 100644 --- a/test/dotnetup.Tests/InfoCommandTests.cs +++ b/test/dotnetup.Tests/InfoCommandTests.cs @@ -13,19 +13,19 @@ public class InfoCommandTests /// /// Creates an InfoCommand instance with the given parameters. /// - private static InfoCommand CreateInfoCommand(bool jsonOutput, bool noList, TextWriter output) + private static InfoCommand CreateInfoCommand(OutputFormat format, bool noList, TextWriter output) { // Create a minimal ParseResult for the command var parseResult = Parser.Parse(new[] { "--info" }); - return new InfoCommand(parseResult, jsonOutput, noList, output); + return new InfoCommand(parseResult, format, noList, output); } /// /// Executes the InfoCommand and returns the exit code. /// - private static int ExecuteInfoCommand(bool jsonOutput, bool noList, TextWriter output) + private static int ExecuteInfoCommand(OutputFormat format, bool noList, TextWriter output) { - var command = CreateInfoCommand(jsonOutput, noList, output); + var command = CreateInfoCommand(format, noList, output); return command.Execute(); } From 36fb6a9fa2d99b4cf42cd608524df0415665bcb2 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 14:19:16 -0800 Subject: [PATCH 30/59] Dashboard now includes runtime metric --- .../Internal/DotnetArchiveExtractor.cs | 1 + .../dotnetup/Telemetry/dotnetup-workbook.json | 64 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index 84ea6ad6972e..81f8a6d5c9d7 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Formats.Tar; using System.IO; using System.IO.Compression; diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index e6c2dd9a945f..42996f8c3ccf 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -358,10 +358,70 @@ { "type": 1, "content": { - "json": "## SDK Installations" + "json": "## Installations" }, "name": "install-header" }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"command/runtime/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend component = coalesce(tostring(customDimensions[\"install.component\"]), \"Runtime\")\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by component\n| render piechart", + "size": 1, + "title": "Runtime Type Distribution", + "noDataMessage": "No runtime install telemetry data yet.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "25", + "name": "runtime-type-distribution" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\" and name endswith \"/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend path_source = coalesce(tostring(customDimensions[\"install.path_source\"]), \"unknown\")\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by path_source\n| render piechart", + "size": 1, + "title": "Install Path Source", + "noDataMessage": "No install path source telemetry data yet.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "25", + "name": "install-path-source-distribution" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\" and name endswith \"/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend path_type = coalesce(tostring(customDimensions[\"install.path_type\"]), \"unknown\")\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by path_type\n| render piechart", + "size": 1, + "title": "Install Path Type", + "noDataMessage": "No install path type telemetry data yet.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "25", + "name": "install-path-type-distribution" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\" and name endswith \"/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend result = coalesce(tostring(customDimensions[\"install.result\"]), iif(success == true, \"success\", \"failed\"))\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by result\n| render piechart", + "size": 1, + "title": "Install Outcome", + "noDataMessage": "No install outcome data yet.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "piechart" + }, + "customWidth": "25", + "name": "install-outcome-distribution" + }, { "type": 3, "content": { @@ -645,4 +705,4 @@ ], "fallbackResourceIds": [], "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json" -} +} \ No newline at end of file From f7cb43558c6d8218c79a443dd66606651776e70a Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 14:40:44 -0800 Subject: [PATCH 31/59] Some failing tests due to concurrency --- test/dotnetup.Tests/DotnetArchiveExtractorTests.cs | 5 +++-- test/dotnetup.Tests/TelemetryTests.cs | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs b/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs index 7c31a4315e04..180c3aa576f2 100644 --- a/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs +++ b/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs @@ -17,6 +17,7 @@ namespace Microsoft.DotNet.Tools.Dotnetup.Tests; /// /// Tests for DotnetArchiveExtractor, particularly error handling scenarios. /// +[Collection("ActivitySourceTests")] public class DotnetArchiveExtractorTests { private readonly ITestOutputHelper _log; @@ -51,8 +52,8 @@ public void Prepare_DownloadFailure_ThrowsException() using var extractor = new DotnetArchiveExtractor(request, version, releaseManifest, progressTarget, mockDownloader); - // Act & Assert - var ex = Assert.Throws(() => extractor.Prepare()); + // Act & Assert - Prepare wraps download failures in DotnetInstallException + var ex = Assert.Throws(() => extractor.Prepare()); _log.WriteLine($"Exception message: {ex.Message}"); ex.Message.Should().Contain("Failed to download"); ex.InnerException!.Message.Should().Contain("Network error"); diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 5b64114c2b7c..23e1109073e8 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -319,6 +319,7 @@ public void RecordException_WithNullActivity_DoesNotThrow() } } +[Collection("ActivitySourceTests")] public class LibraryActivityTagTests { [Fact] @@ -421,6 +422,7 @@ public void ShowIfFirstRun_DoesNotCreateSentinel_WhenTelemetryDisabled() /// Tests for ActivitySource integration - verifies that library consumers can hook into telemetry /// using the pattern demonstrated in TelemetryIntegrationDemo. /// +[Collection("ActivitySourceTests")] public class ActivitySourceIntegrationTests { private const string InstallationActivitySourceName = "Microsoft.Dotnet.Installation"; From 191dbc19c8f5dc33bea7c9d0ecc41d633243ad61 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 15:21:23 -0800 Subject: [PATCH 32/59] Consider that CI machines may set NOLOGO --- test/dotnetup.Tests/TelemetryTests.cs | 37 ++++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 23e1109073e8..c3480054b5c1 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -362,6 +362,8 @@ public void NonUpdatingProgressTarget_SetsCallerTagOnActivity() public class FirstRunNoticeTests { + private const string NoLogoEnvVar = "DOTNET_NOLOGO"; + [Fact] public void IsFirstRun_ReturnsTrueWhenSentinelDoesNotExist() { @@ -379,23 +381,34 @@ public void IsFirstRun_ReturnsTrueWhenSentinelDoesNotExist() [Fact] public void ShowIfFirstRun_CreatesSentinelFile() { - // Clean up any existing sentinel for this test - var sentinelPath = DotnetupPaths.TelemetrySentinelPath; - Assert.NotNull(sentinelPath); + // Save and clear DOTNET_NOLOGO to ensure test runs the full path + var originalNoLogo = Environment.GetEnvironmentVariable(NoLogoEnvVar); + Environment.SetEnvironmentVariable(NoLogoEnvVar, null); - if (File.Exists(sentinelPath)) + try { - File.Delete(sentinelPath); - } + // Clean up any existing sentinel for this test + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; + Assert.NotNull(sentinelPath); + + if (File.Exists(sentinelPath)) + { + File.Delete(sentinelPath); + } - // Simulate first run with telemetry enabled - FirstRunNotice.ShowIfFirstRun(telemetryEnabled: true); + // Simulate first run with telemetry enabled + FirstRunNotice.ShowIfFirstRun(telemetryEnabled: true); - // Sentinel should now exist - Assert.True(File.Exists(sentinelPath)); + // Sentinel should now exist + Assert.True(File.Exists(sentinelPath)); - // Subsequent calls should not be "first run" - Assert.False(FirstRunNotice.IsFirstRun()); + // Subsequent calls should not be "first run" + Assert.False(FirstRunNotice.IsFirstRun()); + } + finally + { + Environment.SetEnvironmentVariable(NoLogoEnvVar, originalNoLogo); + } } [Fact] From 9297c6b6a1b546165f84da065bba2f221cb8d848 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 15:54:20 -0800 Subject: [PATCH 33/59] Try to avoid breaking fullframework build --- src/Layout/redist/targets/Crossgen.targets | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Layout/redist/targets/Crossgen.targets b/src/Layout/redist/targets/Crossgen.targets index 3e07f481af11..25de1e252b2d 100644 --- a/src/Layout/redist/targets/Crossgen.targets +++ b/src/Layout/redist/targets/Crossgen.targets @@ -65,6 +65,8 @@ + + From ced1c2d77ae7611a3c4e592739cfe9d40bf4a029 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 15:54:33 -0800 Subject: [PATCH 34/59] Allow custom time frame optoin on workbook --- src/Installer/dotnetup/Telemetry/dotnetup-workbook.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index 42996f8c3ccf..c1db16be9faa 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -39,8 +39,13 @@ { "durationMs": 2592000000, "displayName": "Last 30 days" + }, + { + "durationMs": 7776000000, + "displayName": "Last 90 days" } - ] + ], + "allowCustom": true }, "label": "Time Range" }, From 4f3f1b6d3f629e913b4d82fbfc1445ed81149247 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 16:07:47 -0800 Subject: [PATCH 35/59] be aware that install path source may be global.json informed --- .../Commands/Shared/InstallExecutor.cs | 26 +- .../Commands/Shared/InstallWorkflow.cs | 2 +- test/dotnetup.Tests/InstallTelemetryTests.cs | 375 ++++++++++++++++++ 3 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 test/dotnetup.Tests/InstallTelemetryTests.cs diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index df6c6cc560ad..67fc02aa487b 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -154,8 +154,11 @@ public static void DisplayComplete() /// /// Classifies the install path for telemetry (no PII - just the type of location). + /// When pathSource is provided, global_json paths are distinguished from other path types. /// - public static string ClassifyInstallPath(string path) + /// The install path to classify. + /// How the path was determined (e.g., "global_json", "explicit"). Null to skip source-based classification. + public static string ClassifyInstallPath(string path, string? pathSource = null) { var fullPath = Path.GetFullPath(path); @@ -173,17 +176,19 @@ public static string ClassifyInstallPath(string path) return "system_programfiles_x86"; } - var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - if (!string.IsNullOrEmpty(userProfile) && fullPath.StartsWith(userProfile, StringComparison.OrdinalIgnoreCase)) - { - return "user_profile"; - } - + // Check more-specific paths before less-specific ones: + // LocalApplicationData (e.g., C:\Users\x\AppData\Local) is under UserProfile (C:\Users\x) var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); if (!string.IsNullOrEmpty(localAppData) && fullPath.StartsWith(localAppData, StringComparison.OrdinalIgnoreCase)) { return "local_appdata"; } + + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(userProfile) && fullPath.StartsWith(userProfile, StringComparison.OrdinalIgnoreCase)) + { + return "user_profile"; + } } else { @@ -200,6 +205,13 @@ public static string ClassifyInstallPath(string path) } } + // If the path was specified by global.json and doesn't match a well-known location, + // classify it as global_json rather than generic "other" + if (pathSource == "global_json") + { + return "global_json"; + } + return "other"; } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index d24712d8b85c..08a5bf21f793 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -73,7 +73,7 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) Activity.Current?.SetTag("install.has_global_json", context.GlobalJson?.GlobalJsonPath is not null); Activity.Current?.SetTag("install.existing_install_type", context.CurrentInstallRoot?.InstallType.ToString() ?? "none"); Activity.Current?.SetTag("install.set_default", context.SetDefaultInstall); - Activity.Current?.SetTag("install.path_type", InstallExecutor.ClassifyInstallPath(context.InstallPath)); + Activity.Current?.SetTag("install.path_type", InstallExecutor.ClassifyInstallPath(context.InstallPath, context.PathSource)); Activity.Current?.SetTag("install.path_source", context.PathSource); // Record request source (how the version/channel was determined) diff --git a/test/dotnetup.Tests/InstallTelemetryTests.cs b/test/dotnetup.Tests/InstallTelemetryTests.cs new file mode 100644 index 000000000000..9c8e8561a89b --- /dev/null +++ b/test/dotnetup.Tests/InstallTelemetryTests.cs @@ -0,0 +1,375 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +using Xunit; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Tests; + +public class ClassifyInstallPathTests +{ + [Fact] + public void ClassifyInstallPath_DefaultInstallPath_ReturnsLocalAppData() + { + // The default install path is LocalApplicationData\dotnet + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localAppData)) + { + return; // Skip on platforms where LocalApplicationData is not set + } + + var path = Path.Combine(localAppData, "dotnet"); + + var result = InstallExecutor.ClassifyInstallPath(path); + + if (OperatingSystem.IsWindows()) + { + Assert.Equal("local_appdata", result); + } + } + + [Fact] + public void ClassifyInstallPath_UserProfilePath_ReturnsUserProfile() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(userProfile)) + { + return; + } + + // A path under user profile but NOT under LocalApplicationData + var path = Path.Combine(userProfile, "my-dotnet"); + + var result = InstallExecutor.ClassifyInstallPath(path); + + Assert.Equal("user_profile", result); + } + + [Fact] + public void ClassifyInstallPath_LocalAppData_IsMoreSpecificThanUserProfile() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (string.IsNullOrEmpty(localAppData) || string.IsNullOrEmpty(userProfile)) + { + return; + } + + // LocalAppData is under UserProfile — verify the more specific match wins + Assert.StartsWith(userProfile, localAppData, StringComparison.OrdinalIgnoreCase); + + var path = Path.Combine(localAppData, "dotnet"); + var result = InstallExecutor.ClassifyInstallPath(path); + + // Should be local_appdata, not user_profile + Assert.Equal("local_appdata", result); + } + + [Fact] + public void ClassifyInstallPath_ProgramFiles_ReturnsSystemProgramfiles() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (string.IsNullOrEmpty(programFiles)) + { + return; + } + + var path = Path.Combine(programFiles, "dotnet"); + + var result = InstallExecutor.ClassifyInstallPath(path); + + Assert.Equal("system_programfiles", result); + } + + [Fact] + public void ClassifyInstallPath_UnknownPath_ReturnsOther() + { + // A path that doesn't match any known category + var path = OperatingSystem.IsWindows() + ? @"D:\custom\dotnet" + : "/tmp/custom/dotnet"; + + var result = InstallExecutor.ClassifyInstallPath(path); + + Assert.Equal("other", result); + } + + [Fact] + public void ClassifyInstallPath_GlobalJsonSource_UnknownPath_ReturnsGlobalJson() + { + // When pathSource is global_json and the path doesn't match a known location, + // the result should be "global_json" instead of "other" + var path = OperatingSystem.IsWindows() + ? @"D:\repo\.dotnet" + : "/tmp/repo/.dotnet"; + + var result = InstallExecutor.ClassifyInstallPath(path, pathSource: "global_json"); + + Assert.Equal("global_json", result); + } + + [Fact] + public void ClassifyInstallPath_GlobalJsonSource_KnownPath_ReturnsKnownType() + { + // When pathSource is global_json but the path is a well-known location, + // the well-known classification should still win + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localAppData)) + { + return; + } + + var path = Path.Combine(localAppData, "dotnet"); + + var result = InstallExecutor.ClassifyInstallPath(path, pathSource: "global_json"); + + if (OperatingSystem.IsWindows()) + { + Assert.Equal("local_appdata", result); + } + else + { + // On non-Windows, LocalApplicationData may match user_home + Assert.NotEqual("other", result); + } + } + + [Fact] + public void ClassifyInstallPath_ExplicitSource_UnknownPath_ReturnsOther() + { + // Non-global_json source should still return "other" for unknown paths + var path = OperatingSystem.IsWindows() + ? @"D:\custom\dotnet" + : "/tmp/custom/dotnet"; + + var result = InstallExecutor.ClassifyInstallPath(path, pathSource: "explicit"); + + Assert.Equal("other", result); + } + + [Fact] + public void ClassifyInstallPath_NullPathSource_UnknownPath_ReturnsOther() + { + var path = OperatingSystem.IsWindows() + ? @"D:\custom\dotnet" + : "/tmp/custom/dotnet"; + + var result = InstallExecutor.ClassifyInstallPath(path, pathSource: null); + + Assert.Equal("other", result); + } + + [Fact] + public void ClassifyInstallPath_UsrPath_ReturnsSystemPath() + { + if (OperatingSystem.IsWindows()) return; + + var result = InstallExecutor.ClassifyInstallPath("/usr/share/dotnet"); + + Assert.Equal("system_path", result); + } + + [Fact] + public void ClassifyInstallPath_OptPath_ReturnsSystemPath() + { + if (OperatingSystem.IsWindows()) return; + + var result = InstallExecutor.ClassifyInstallPath("/opt/dotnet"); + + Assert.Equal("system_path", result); + } + + [Fact] + public void ClassifyInstallPath_HomePath_ReturnsUserHome() + { + if (OperatingSystem.IsWindows()) return; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) + { + return; + } + + var path = Path.Combine(home, ".dotnet"); + + var result = InstallExecutor.ClassifyInstallPath(path); + + Assert.Equal("user_home", result); + } +} + +public class ApplyErrorTagsTests +{ + [Fact] + public void ApplyErrorTags_SetsAllRequiredTags() + { + using var listener = CreateTestListener(out var captured); + + using var source = new ActivitySource("ApplyErrorTags.Test.1"); + using (var activity = source.StartActivity("test")) + { + Assert.NotNull(activity); + + var errorInfo = new ExceptionErrorInfo( + ErrorType: "DiskFull", + HResult: unchecked((int)0x80070070), + StatusCode: null, + Details: "win32_error_112", + SourceLocation: "InstallExecutor.cs:42", + ExceptionChain: "IOException", + Category: ErrorCategory.User); + + ErrorCodeMapper.ApplyErrorTags(activity, errorInfo); + } + + var a = Assert.Single(captured); + Assert.Equal("DiskFull", a.GetTagItem("error.type")); + Assert.Equal("user", a.GetTagItem("error.category")); + Assert.Equal(unchecked((int)0x80070070), a.GetTagItem("error.hresult")); + Assert.Equal("win32_error_112", a.GetTagItem("error.details")); + Assert.Equal("InstallExecutor.cs:42", a.GetTagItem("error.source_location")); + Assert.Equal("IOException", a.GetTagItem("error.exception_chain")); + } + + [Fact] + public void ApplyErrorTags_WithErrorCode_SetsErrorCodeTag() + { + using var listener = CreateTestListener(out var captured); + + using var source = new ActivitySource("ApplyErrorTags.Test.2"); + using (var activity = source.StartActivity("test")) + { + Assert.NotNull(activity); + + var errorInfo = new ExceptionErrorInfo( + ErrorType: "HttpError", + HResult: null, + StatusCode: 404, + Details: null, + SourceLocation: null, + ExceptionChain: "HttpRequestException", + Category: ErrorCategory.Product); + + ErrorCodeMapper.ApplyErrorTags(activity, errorInfo, errorCode: "Http404"); + } + + var a = Assert.Single(captured); + Assert.Equal("HttpError", a.GetTagItem("error.type")); + Assert.Equal("Http404", a.GetTagItem("error.code")); + Assert.Equal("product", a.GetTagItem("error.category")); + Assert.Equal(404, a.GetTagItem("error.http_status")); + Assert.Null(a.GetTagItem("error.hresult")); + Assert.Null(a.GetTagItem("error.details")); + } + + [Fact] + public void ApplyErrorTags_WithNullActivity_DoesNotThrow() + { + var errorInfo = new ExceptionErrorInfo( + ErrorType: "Test", + HResult: null, + StatusCode: null, + Details: null, + SourceLocation: null, + ExceptionChain: null, + Category: ErrorCategory.Product); + + var ex = Record.Exception(() => ErrorCodeMapper.ApplyErrorTags(null, errorInfo)); + + Assert.Null(ex); + } + + [Fact] + public void ApplyErrorTags_NullOptionalFields_DoesNotSetOptionalTags() + { + using var listener = CreateTestListener(out var captured); + + using var source = new ActivitySource("ApplyErrorTags.Test.3"); + using (var activity = source.StartActivity("test")) + { + Assert.NotNull(activity); + + var errorInfo = new ExceptionErrorInfo( + ErrorType: "GenericError", + HResult: null, + StatusCode: null, + Details: null, + SourceLocation: null, + ExceptionChain: null, + Category: ErrorCategory.Product); + + ErrorCodeMapper.ApplyErrorTags(activity, errorInfo); + } + + var a = Assert.Single(captured); + Assert.Equal("GenericError", a.GetTagItem("error.type")); + Assert.Equal("product", a.GetTagItem("error.category")); + // Optional tags should not be set + Assert.Null(a.GetTagItem("error.hresult")); + Assert.Null(a.GetTagItem("error.http_status")); + Assert.Null(a.GetTagItem("error.details")); + Assert.Null(a.GetTagItem("error.source_location")); + Assert.Null(a.GetTagItem("error.exception_chain")); + Assert.Null(a.GetTagItem("error.code")); + } + + [Fact] + public void ApplyErrorTags_SetsActivityStatusToError() + { + using var listener = CreateTestListener(out var captured); + + using var source = new ActivitySource("ApplyErrorTags.Test.4"); + using (var activity = source.StartActivity("test")) + { + Assert.NotNull(activity); + + var errorInfo = new ExceptionErrorInfo( + ErrorType: "TestError", + HResult: null, + StatusCode: null, + Details: null, + SourceLocation: null, + ExceptionChain: null, + Category: ErrorCategory.Product); + + ErrorCodeMapper.ApplyErrorTags(activity, errorInfo); + } + + var a = Assert.Single(captured); + Assert.Equal(ActivityStatusCode.Error, a.Status); + Assert.Equal("TestError", a.StatusDescription); + } + + private static ActivityListener CreateTestListener(out List captured) + { + var list = new List(); + captured = list; + var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => list.Add(activity) + }; + ActivitySource.AddActivityListener(listener); + return listener; + } +} From e7f575ec8a983d4294ff6c67d3c982ba4aa87036 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 16:15:39 -0800 Subject: [PATCH 36/59] Record sha for dev builds This is so we can separate failures based on shas when we see errors installing the .NET SDK and also we can separate prod failures from dev failures even more easily --- .../Telemetry/TelemetryCommonProperties.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs index 73536ccb9691..01f56a265da4 100644 --- a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs +++ b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs @@ -146,10 +146,21 @@ private static bool DetectDevBuild() #endif } - private static string GetVersion() + internal static string GetVersion() { - return typeof(TelemetryCommonProperties).Assembly - .GetCustomAttribute()?.InformationalVersion - ?? "0.0.0"; + var version = BuildInfo.Version; + + // For dev builds, append the commit SHA so we can correlate failures to specific commits. + // Prod telemetry stays clean (e.g., "10.0.100-alpha"), while dev shows "10.0.100-alpha@abc1234". + if (s_isDevBuild.Value) + { + var commitSha = BuildInfo.CommitSha; + if (commitSha != "unknown") + { + return $"{version}@{commitSha}"; + } + } + + return version; } } From a22bd2831f18e189d88c8c8df7d31ac858ff0c71 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 16:21:57 -0800 Subject: [PATCH 37/59] Track and block attempts to install to admin location --- .../Commands/Shared/InstallExecutor.cs | 46 +++++ .../Commands/Shared/InstallWorkflow.cs | 13 ++ .../dotnetup/DotnetInstallManager.cs | 7 +- test/dotnetup.Tests/InstallTelemetryTests.cs | 188 +++++++++++++++++- 4 files changed, 247 insertions(+), 7 deletions(-) diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index 67fc02aa487b..11b436364571 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -152,6 +152,46 @@ public static void DisplayComplete() SpectreAnsiConsole.WriteLine("Complete!"); } + /// + /// Determines whether the given path is an admin/system-managed .NET install location. + /// These locations are managed by system package managers or OS installers and should not + /// be used by dotnetup for user-level installations. + /// + public static bool IsAdminInstallPath(string path) + { + var fullPath = Path.GetFullPath(path); + + if (OperatingSystem.IsWindows()) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + // Check for C:\Program Files\dotnet or C:\Program Files (x86)\dotnet + if (!string.IsNullOrEmpty(programFiles) && + fullPath.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (!string.IsNullOrEmpty(programFilesX86) && + fullPath.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + else + { + // Standard admin/package-manager locations on Linux and macOS + if (fullPath.StartsWith("/usr/share/dotnet", StringComparison.Ordinal) || + fullPath.StartsWith("/usr/lib/dotnet", StringComparison.Ordinal) || + fullPath.StartsWith("/usr/local/share/dotnet", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + /// /// Classifies the install path for telemetry (no PII - just the type of location). /// When pathSource is provided, global_json paths are distinguished from other path types. @@ -162,6 +202,12 @@ public static string ClassifyInstallPath(string path, string? pathSource = null) { var fullPath = Path.GetFullPath(path); + // Check for admin/system .NET paths first — these are the most important to distinguish + if (IsAdminInstallPath(path)) + { + return "admin"; + } + if (OperatingSystem.IsWindows()) { var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index 08a5bf21f793..56d3bcfe6486 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -69,6 +69,19 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) return new InstallWorkflowResult(1, null); } + // Block admin/system-managed install paths — dotnetup should not install there + if (InstallExecutor.IsAdminInstallPath(context.InstallPath)) + { + Console.Error.WriteLine($"Error: The install path '{context.InstallPath}' is a system-managed .NET location. " + + "dotnetup installs to user-level locations only. " + + "Use your system package manager or the official installer for system-wide installations."); + Activity.Current?.SetTag("error.type", "admin_path_blocked"); + Activity.Current?.SetTag("install.path_type", "admin"); + Activity.Current?.SetTag("install.path_source", context.PathSource); + Activity.Current?.SetTag("error.category", "user"); + return new InstallWorkflowResult(1, null); + } + // Record resolved context telemetry Activity.Current?.SetTag("install.has_global_json", context.GlobalJson?.GlobalJsonPath is not null); Activity.Current?.SetTag("install.existing_install_type", context.CurrentInstallRoot?.InstallType.ToString() ?? "none"); diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index d99e512e0a4f..0f16fccffd71 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -7,6 +7,7 @@ using System.Text.Json; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -60,11 +61,7 @@ public DotnetInstallManager(IEnvironmentProvider? environmentProvider = null) else { // For non-Windows platforms, determine based on path location - // TODO: This should be improved to not be windows-specific https://github.com/dotnet/sdk/issues/51601 - string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - bool isAdminInstall = currentInstallRoot.Path.StartsWith(Path.Combine(programFiles, "dotnet"), StringComparison.OrdinalIgnoreCase) || - currentInstallRoot.Path.StartsWith(Path.Combine(programFilesX86, "dotnet"), StringComparison.OrdinalIgnoreCase); + bool isAdminInstall = InstallExecutor.IsAdminInstallPath(currentInstallRoot.Path); // For now, we consider it fully configured if it's on PATH return new(currentInstallRoot, isAdminInstall ? InstallType.Admin : InstallType.User, IsFullyConfigured: true); diff --git a/test/dotnetup.Tests/InstallTelemetryTests.cs b/test/dotnetup.Tests/InstallTelemetryTests.cs index 9c8e8561a89b..49cf76485d0a 100644 --- a/test/dotnetup.Tests/InstallTelemetryTests.cs +++ b/test/dotnetup.Tests/InstallTelemetryTests.cs @@ -80,7 +80,7 @@ public void ClassifyInstallPath_LocalAppData_IsMoreSpecificThanUserProfile() } [Fact] - public void ClassifyInstallPath_ProgramFiles_ReturnsSystemProgramfiles() + public void ClassifyInstallPath_ProgramFilesDotnet_ReturnsAdmin() { if (!OperatingSystem.IsWindows()) { @@ -97,6 +97,28 @@ public void ClassifyInstallPath_ProgramFiles_ReturnsSystemProgramfiles() var result = InstallExecutor.ClassifyInstallPath(path); + Assert.Equal("admin", result); + } + + [Fact] + public void ClassifyInstallPath_ProgramFilesNonDotnet_ReturnsSystemProgramfiles() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (string.IsNullOrEmpty(programFiles)) + { + return; + } + + // A path under Program Files that is NOT dotnet — still system_programfiles + var path = Path.Combine(programFiles, "SomeOtherTool"); + + var result = InstallExecutor.ClassifyInstallPath(path); + Assert.Equal("system_programfiles", result); } @@ -179,12 +201,23 @@ public void ClassifyInstallPath_NullPathSource_UnknownPath_ReturnsOther() } [Fact] - public void ClassifyInstallPath_UsrPath_ReturnsSystemPath() + public void ClassifyInstallPath_UsrShareDotnet_ReturnsAdmin() { if (OperatingSystem.IsWindows()) return; var result = InstallExecutor.ClassifyInstallPath("/usr/share/dotnet"); + Assert.Equal("admin", result); + } + + [Fact] + public void ClassifyInstallPath_UsrLocalBin_ReturnsSystemPath() + { + if (OperatingSystem.IsWindows()) return; + + // /usr/local/bin is a system path but NOT an admin dotnet location + var result = InstallExecutor.ClassifyInstallPath("/usr/local/bin/something"); + Assert.Equal("system_path", result); } @@ -373,3 +406,154 @@ private static ActivityListener CreateTestListener(out List captured) return listener; } } + +public class IsAdminInstallPathTests +{ + [Fact] + public void IsAdminInstallPath_ProgramFilesDotnet_ReturnsTrue() + { + if (!OperatingSystem.IsWindows()) return; + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (string.IsNullOrEmpty(programFiles)) return; + + Assert.True(InstallExecutor.IsAdminInstallPath(Path.Combine(programFiles, "dotnet"))); + } + + [Fact] + public void IsAdminInstallPath_ProgramFilesX86Dotnet_ReturnsTrue() + { + if (!OperatingSystem.IsWindows()) return; + + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + if (string.IsNullOrEmpty(programFilesX86)) return; + + Assert.True(InstallExecutor.IsAdminInstallPath(Path.Combine(programFilesX86, "dotnet"))); + } + + [Fact] + public void IsAdminInstallPath_ProgramFilesSubfolder_ReturnsTrue() + { + if (!OperatingSystem.IsWindows()) return; + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (string.IsNullOrEmpty(programFiles)) return; + + // Subfolders of Program Files\dotnet should also be blocked + Assert.True(InstallExecutor.IsAdminInstallPath(Path.Combine(programFiles, "dotnet", "sdk"))); + } + + [Fact] + public void IsAdminInstallPath_UserPath_ReturnsFalse() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(localAppData)) return; + + Assert.False(InstallExecutor.IsAdminInstallPath(Path.Combine(localAppData, "dotnet"))); + } + + [Fact] + public void IsAdminInstallPath_CustomPath_ReturnsFalse() + { + var path = OperatingSystem.IsWindows() + ? @"D:\custom\dotnet" + : "/tmp/custom/dotnet"; + + Assert.False(InstallExecutor.IsAdminInstallPath(path)); + } + + [Fact] + public void IsAdminInstallPath_UsrShareDotnet_ReturnsTrue() + { + if (OperatingSystem.IsWindows()) return; + + Assert.True(InstallExecutor.IsAdminInstallPath("/usr/share/dotnet")); + } + + [Fact] + public void IsAdminInstallPath_UsrLibDotnet_ReturnsTrue() + { + if (OperatingSystem.IsWindows()) return; + + Assert.True(InstallExecutor.IsAdminInstallPath("/usr/lib/dotnet")); + } + + [Fact] + public void IsAdminInstallPath_UsrLocalShareDotnet_ReturnsTrue() + { + if (OperatingSystem.IsWindows()) return; + + Assert.True(InstallExecutor.IsAdminInstallPath("/usr/local/share/dotnet")); + } + + [Fact] + public void IsAdminInstallPath_OptPath_ReturnsFalse() + { + if (OperatingSystem.IsWindows()) return; + + // /opt/dotnet is a system path but not an admin dotnet location + Assert.False(InstallExecutor.IsAdminInstallPath("/opt/dotnet")); + } + + [Fact] + public void IsAdminInstallPath_HomePath_ReturnsFalse() + { + if (OperatingSystem.IsWindows()) return; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrEmpty(home)) return; + + Assert.False(InstallExecutor.IsAdminInstallPath(Path.Combine(home, ".dotnet"))); + } + + [Fact] + public void IsAdminInstallPath_ProgramFilesNonDotnet_ReturnsFalse() + { + if (!OperatingSystem.IsWindows()) return; + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (string.IsNullOrEmpty(programFiles)) return; + + // Program Files\SomeOtherTool is NOT an admin dotnet path + Assert.False(InstallExecutor.IsAdminInstallPath(Path.Combine(programFiles, "SomeOtherTool"))); + } +} + +public class GetVersionTests +{ + [Fact] + public void GetVersion_ReturnsNonEmptyVersion() + { + var version = TelemetryCommonProperties.GetVersion(); + + Assert.False(string.IsNullOrEmpty(version)); + } + + [Fact] + public void GetVersion_DevBuild_ContainsAtSymbol() + { + // In test builds (compiled as Debug), GetVersion should include @commitsha + // since DetectDevBuild returns true for DEBUG builds + var version = TelemetryCommonProperties.GetVersion(); + + // The version should contain @ if the commit SHA is known + if (BuildInfo.CommitSha != "unknown") + { + Assert.Contains("@", version); + // The part after @ should be the commit SHA + var parts = version.Split('@'); + Assert.Equal(2, parts.Length); + Assert.Equal(BuildInfo.CommitSha, parts[1]); + } + } + + [Fact] + public void GetVersion_VersionPartMatchesBuildInfoVersion() + { + var version = TelemetryCommonProperties.GetVersion(); + + // The version part (before @) should match BuildInfo.Version + var versionPart = version.Split('@')[0]; + Assert.Equal(BuildInfo.Version, versionPart); + } +} From 7eca6f4994b38ac7ce6133b5ad590d9296c165ad Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 9 Feb 2026 16:33:50 -0800 Subject: [PATCH 38/59] Consider fetching more network failure info --- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 116 +++++++++++++++++- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 78 ++++++++++++ 2 files changed, 188 insertions(+), 6 deletions(-) diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 2be57a5b320e..93722dd08c00 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Net; +using System.Net.Sockets; using System.Runtime.InteropServices; using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; @@ -115,12 +116,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) { // DotnetInstallException has specific error codes - categorize by error code // Sanitize the version to prevent PII leakage (user could have typed anything) - DotnetInstallException installEx => new ExceptionErrorInfo( - installEx.ErrorCode.ToString(), - Category: GetInstallErrorCategory(installEx.ErrorCode), - Details: installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), + // For network-related errors, also check the inner exception for more details + DotnetInstallException installEx => GetInstallExceptionErrorInfo(installEx, sourceLocation, exceptionChain), // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues HttpRequestException httpEx => new ExceptionErrorInfo( @@ -234,6 +231,113 @@ private static ErrorCategory GetInstallErrorCategory(DotnetInstallErrorCode erro }; } + /// + /// Gets error info for a DotnetInstallException, enriching with inner exception details + /// for network-related errors. + /// + private static ExceptionErrorInfo GetInstallExceptionErrorInfo( + DotnetInstallException installEx, + string? sourceLocation, + string? exceptionChain) + { + var errorCode = installEx.ErrorCode; + var baseCategory = GetInstallErrorCategory(errorCode); + var details = installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null; + int? httpStatus = null; + + // For network-related errors, check the inner exception to better categorize + // and extract additional diagnostic info + if (IsNetworkRelatedErrorCode(errorCode) && installEx.InnerException is not null) + { + var (refinedCategory, innerHttpStatus, innerDetails) = AnalyzeNetworkException(installEx.InnerException); + baseCategory = refinedCategory; + httpStatus = innerHttpStatus; + + // Combine details: version + inner exception info + if (innerDetails is not null) + { + details = details is not null ? $"{details};{innerDetails}" : innerDetails; + } + } + + return new ExceptionErrorInfo( + errorCode.ToString(), + Category: baseCategory, + StatusCode: httpStatus, + Details: details, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain); + } + + /// + /// Checks if the error code is related to network operations. + /// + private static bool IsNetworkRelatedErrorCode(DotnetInstallErrorCode errorCode) + { + return errorCode is + DotnetInstallErrorCode.ManifestFetchFailed or + DotnetInstallErrorCode.DownloadFailed or + DotnetInstallErrorCode.NetworkError; + } + + /// + /// Analyzes a network-related inner exception to determine the category and extract details. + /// + private static (ErrorCategory Category, int? HttpStatus, string? Details) AnalyzeNetworkException(Exception inner) + { + // Walk the exception chain to find HttpRequestException or SocketException + // Look for the most specific info we can find + HttpRequestException? foundHttpEx = null; + SocketException? foundSocketEx = null; + + var current = inner; + while (current is not null) + { + if (current is HttpRequestException httpEx && foundHttpEx is null) + { + foundHttpEx = httpEx; + } + + if (current is SocketException socketEx && foundSocketEx is null) + { + foundSocketEx = socketEx; + } + + current = current.InnerException; + } + + // Prefer socket-level info if available (more specific) + if (foundSocketEx is not null) + { + var socketErrorName = foundSocketEx.SocketErrorCode.ToString().ToLowerInvariant(); + return (ErrorCategory.User, null, $"socket_{socketErrorName}"); + } + + // Then HTTP-level info + if (foundHttpEx is not null) + { + var category = GetHttpErrorCategory(foundHttpEx.StatusCode); + var httpStatus = (int?)foundHttpEx.StatusCode; + + string? details = null; + if (foundHttpEx.StatusCode.HasValue) + { + details = $"http_{(int)foundHttpEx.StatusCode}"; + } + else if (foundHttpEx.HttpRequestError != HttpRequestError.Unknown) + { + // .NET 7+ has HttpRequestError enum for non-HTTP failures + details = $"request_error_{foundHttpEx.HttpRequestError.ToString().ToLowerInvariant()}"; + } + + return (category, httpStatus, details); + } + + // Couldn't determine from inner exception - use default Product category + // but mark as unknown network error + return (ErrorCategory.Product, null, "network_unknown"); + } + /// /// Gets the error category for an HTTP status code. /// diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index 0ed353375099..4599fa8285aa 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -419,6 +419,84 @@ public void GetErrorInfo_HttpRequestException_NoStatusCode_IsUserError() Assert.Equal(ErrorCategory.User, info.Category); } + [Fact] + public void GetErrorInfo_ManifestFetchFailed_WithInnerHttpException_NoStatus_IsUserError() + { + // Network connectivity failure during manifest fetch should be User, not Product + var innerEx = new HttpRequestException("Error while copying content to a stream"); + var ex = new DotnetInstallException( + DotnetInstallErrorCode.ManifestFetchFailed, + $"Failed to fetch release manifest: {innerEx.Message}", + innerEx); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + Assert.Equal("ManifestFetchFailed", info.ErrorType); + } + + [Fact] + public void GetErrorInfo_ManifestFetchFailed_WithInner500Error_IsProductError() + { + // Server errors (5xx) during manifest fetch should be Product + var innerEx = new HttpRequestException("Internal server error", null, System.Net.HttpStatusCode.InternalServerError); + var ex = new DotnetInstallException( + DotnetInstallErrorCode.ManifestFetchFailed, + $"Failed to fetch release manifest: {innerEx.Message}", + innerEx); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.Product, info.Category); + Assert.Equal(500, info.StatusCode); + Assert.Contains("http_500", info.Details!); + } + + [Fact] + public void GetErrorInfo_ManifestFetchFailed_WithInnerSocketException_IsUserError() + { + // Socket errors are user environment issues + var socketEx = new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.HostNotFound); + var httpEx = new HttpRequestException("Error", socketEx); + var ex = new DotnetInstallException( + DotnetInstallErrorCode.ManifestFetchFailed, + $"Failed to fetch release manifest: {httpEx.Message}", + httpEx); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + Assert.Contains("socket_", info.Details!); + } + + [Fact] + public void GetErrorInfo_DownloadFailed_WithInnerHttpException_NoStatus_IsUserError() + { + // Network connectivity failure during download should be User + var innerEx = new HttpRequestException("Network error"); + var ex = new DotnetInstallException( + DotnetInstallErrorCode.DownloadFailed, + $"Download failed: {innerEx.Message}", + innerEx); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.User, info.Category); + } + + [Fact] + public void GetErrorInfo_ManifestFetchFailed_NoInnerException_IsProductError() + { + // Without inner exception info, we can't determine it's a user issue - default to Product + var ex = new DotnetInstallException( + DotnetInstallErrorCode.ManifestFetchFailed, + "Failed to fetch release manifest"); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal(ErrorCategory.Product, info.Category); + } + private class CustomTestException : Exception { public CustomTestException(string message) : base(message) { } From ba2c7eac7dd66a63978430cdee493223eb68f7a5 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 19 Feb 2026 10:31:39 -0800 Subject: [PATCH 39/59] work for other powershell installs besides pwsh --- test/dotnetup.Tests/DnupE2Etest.cs | 45 ++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/test/dotnetup.Tests/DnupE2Etest.cs b/test/dotnetup.Tests/DnupE2Etest.cs index c0ea7c542794..dafa35211113 100644 --- a/test/dotnetup.Tests/DnupE2Etest.cs +++ b/test/dotnetup.Tests/DnupE2Etest.cs @@ -225,11 +225,19 @@ private static void VerifyEnvScriptWorks(string shell, string installPath, strin }); chmod?.WaitForExit(); } - else // pwsh + else // pwsh / powershell { static string Escape(string s) => s.Replace("'", "''"); - shellExecutable = "pwsh"; + // Prefer pwsh (PowerShell Core, cross-platform) over powershell.exe (Windows PowerShell 5.1). + // The generated env script uses standard PowerShell syntax compatible with both. + shellExecutable = ResolvePowerShellExecutable()!; + if (shellExecutable is null) + { + Console.WriteLine("Skipping pwsh test - neither pwsh nor powershell.exe found on PATH"); + return; + } + scriptPath = Path.Combine(tempRoot, "test-env.ps1"); scriptContent = $@" $ErrorActionPreference = 'Stop' @@ -361,6 +369,37 @@ private static void VerifyManifestContains(TestEnvironment testEnv, InstallCompo additionalAssertions?.Invoke(matchingInstalls[0]); } + + /// + /// Resolves a PowerShell executable, preferring pwsh (PowerShell Core) over powershell.exe (Windows PowerShell 5.1). + /// Returns null if neither is found. + /// + private static string? ResolvePowerShellExecutable() + { + // Try pwsh first (cross-platform PowerShell Core, matches the tool's --shell pwsh argument) + foreach (var candidate in new[] { "pwsh", "powershell" }) + { + try + { + using var probe = new Process(); + probe.StartInfo.FileName = candidate; + probe.StartInfo.Arguments = "-NoProfile -Command \"exit 0\""; + probe.StartInfo.UseShellExecute = false; + probe.StartInfo.RedirectStandardOutput = true; + probe.StartInfo.RedirectStandardError = true; + probe.StartInfo.CreateNoWindow = true; + probe.Start(); + probe.WaitForExit(5000); + return candidate; + } + catch (Exception) + { + // Not found, try next candidate + } + } + + return null; + } } /// @@ -532,4 +571,6 @@ public void RuntimeInstall_AfterSdkInstall_BehavesCorrectly(string componentSpec output.Should().NotContain("Downloading", "Should not download when files already exist from SDK"); } } + + /// } From 041ed4abba4eeb25726b4d18382f7e328bee4cd0 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 19 Feb 2026 10:51:06 -0800 Subject: [PATCH 40/59] pr feedback --- .../dotnetup/Commands/Shared/InstallPathResolver.cs | 2 +- src/Installer/dotnetup/DotnetInstallManager.cs | 6 +++--- src/Installer/dotnetup/DotnetupPaths.cs | 2 +- src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs | 2 -- src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs | 2 -- test/dotnetup.Tests/InfoCommandTests.cs | 4 ---- 6 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs index 4be0d67e2b89..55063f008346 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs @@ -85,7 +85,7 @@ public record InstallPathResolutionResult( if (interactive) { var prompted = SpectreAnsiConsole.Prompt( - new TextPrompt($"Where should we install the {componentDescription} to?)") + new TextPrompt($"Where should we install the {componentDescription} to?") .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); return new InstallPathResolutionResult(prompted, installPathFromGlobalJson, "interactive_prompt"); } diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index 0f16fccffd71..a56227611091 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -113,11 +113,11 @@ public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressCo } } - private void InstallSDK(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, UpdateChannel channnel) + private void InstallSDK(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, UpdateChannel channel) { DotnetInstallRequest request = new DotnetInstallRequest( dotnetRoot, - channnel, + channel, InstallComponent.SDK, new InstallRequestOptions() ); @@ -125,7 +125,7 @@ private void InstallSDK(DotnetInstallRoot dotnetRoot, ProgressContext progressCo InstallResult installResult = InstallerOrchestratorSingleton.Instance.Install(request); if (installResult.Install == null) { - throw new Exception($"Failed to install .NET SDK {channnel.Name}"); + throw new Exception($"Failed to install .NET SDK {channel.Name}"); } else { diff --git a/src/Installer/dotnetup/DotnetupPaths.cs b/src/Installer/dotnetup/DotnetupPaths.cs index f1078c857404..1a6076d7a44d 100644 --- a/src/Installer/dotnetup/DotnetupPaths.cs +++ b/src/Installer/dotnetup/DotnetupPaths.cs @@ -20,7 +20,7 @@ internal static class DotnetupPaths /// /// Gets the base data directory for dotnetup. /// On Windows: %LOCALAPPDATA%\dotnetup - /// On Unix: ~/.dotnetup (hidden folder in user profile) + /// On Unix: ~/dotnetup (folder in user profile) /// /// /// Returns null if the base directory cannot be determined. diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index 1e9974a51106..bedb2c93116a 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -3,9 +3,7 @@ using System.Diagnostics; using System.Reflection; -using System.Runtime.InteropServices; using Azure.Monitor.OpenTelemetry.Exporter; -using Microsoft.DotNet.Cli.Utils; using OpenTelemetry; using OpenTelemetry.Resources; using OpenTelemetry.Trace; diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 93722dd08c00..86bff773afea 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -1,13 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.ComponentModel; using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; using Microsoft.Dotnet.Installation; -using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; diff --git a/test/dotnetup.Tests/InfoCommandTests.cs b/test/dotnetup.Tests/InfoCommandTests.cs index 2bdd6fc55869..dd53062b1ab6 100644 --- a/test/dotnetup.Tests/InfoCommandTests.cs +++ b/test/dotnetup.Tests/InfoCommandTests.cs @@ -1,11 +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.CommandLine.Parsing; using System.Text.Json; -using Microsoft.DotNet.Tools.Bootstrapper; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.Info; - namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public class InfoCommandTests From 83e477537f3bd517e1c2a8f99155f4077e1d58cc Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 19 Feb 2026 14:47:42 -0800 Subject: [PATCH 41/59] PR Feedback - Separate Error Mapping It looks like we should keep the mapping of hr results to strings. There are error.message results but those may contain paths. I'm still hesistent to hard code all of these but found nothing in the runtime that provides the specific string mapping from the int hr result itself https://github.com/dotnet/runtime/blob/f84f00920e1719071492b260375af0d4403d340e/src/libraries/Common/src/System/HResults.cs#L67 <- they have this but there is no reversal of their string onto the int as a mapping, and I don't want to take some weird dependency on this file --- .../Internal/ChannelVersionResolver.cs | 23 +- src/Installer/dotnetup/CommandBase.cs | 3 - src/Installer/dotnetup/DotnetupPaths.cs | 8 + .../dotnetup/NonUpdatingProgressTarget.cs | 1 - src/Installer/dotnetup/Program.cs | 2 +- .../dotnetup/SpectreProgressTarget.cs | 1 - .../dotnetup/Telemetry/DotnetupTelemetry.cs | 7 +- .../Telemetry/ErrorCategoryClassifier.cs | 107 ++++ .../dotnetup/Telemetry/ErrorCodeMapper.cs | 552 +----------------- .../Telemetry/ExceptionErrorMapper.cs | 178 ++++++ .../dotnetup/Telemetry/ExceptionInspector.cs | 151 +++++ .../dotnetup/Telemetry/HResultMapper.cs | 82 +++ .../Telemetry/NetworkErrorAnalyzer.cs | 89 +++ .../dotnetup/docs/telemetry-notice.txt | 11 +- test/dotnetup.Tests/DnupE2Etest.cs | 2 - test/dotnetup.Tests/InfoCommandTests.cs | 3 + test/dotnetup.Tests/InstallTelemetryTests.cs | 1 - test/dotnetup.Tests/TelemetryTests.cs | 51 +- 18 files changed, 691 insertions(+), 581 deletions(-) create mode 100644 src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs create mode 100644 src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs create mode 100644 src/Installer/dotnetup/Telemetry/ExceptionInspector.cs create mode 100644 src/Installer/dotnetup/Telemetry/HResultMapper.cs create mode 100644 src/Installer/dotnetup/Telemetry/NetworkErrorAnalyzer.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs index cc7661b3c55a..aeee088633b0 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs @@ -132,12 +132,29 @@ public static bool IsValidChannelFormat(string channel) if (parts.Length >= 3) { var patch = parts[2]; - // Allow feature band pattern (e.g., "1xx", "100") or patch number - var normalizedPatch = patch.Replace("x", "").Replace("X", ""); - if (normalizedPatch.Length > 0 && !int.TryParse(normalizedPatch, out _)) + if (string.IsNullOrEmpty(patch)) { return false; } + + // Allow either: + // - a fully specified numeric patch (e.g., "103"), or + // - a feature band pattern with a numeric prefix and "xx" suffix (e.g., "1xx", "101xx"). + if (patch.EndsWith("xx", StringComparison.OrdinalIgnoreCase)) + { + var prefix = patch.Substring(0, patch.Length - 2); + if (prefix.Length == 0 || !int.TryParse(prefix, out _)) + { + return false; + } + } + else + { + if (!int.TryParse(patch, out var numericPatch) || numericPatch < 0) + { + return false; + } + } } return true; diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index 427fb312d4f8..fde5e0b60651 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -1,11 +1,8 @@ // 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.CommandLine; using System.Diagnostics; -using System.Text; using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; diff --git a/src/Installer/dotnetup/DotnetupPaths.cs b/src/Installer/dotnetup/DotnetupPaths.cs index 1a6076d7a44d..5cd1fdff9ed2 100644 --- a/src/Installer/dotnetup/DotnetupPaths.cs +++ b/src/Installer/dotnetup/DotnetupPaths.cs @@ -24,11 +24,19 @@ internal static class DotnetupPaths /// /// /// Returns null if the base directory cannot be determined. + /// Can be overridden via DOTNET_TESTHOOK_DOTNETUP_DATA_DIR environment variable. /// public static string? DataDirectory { get { + // Allow override for testing — avoids touching the real user profile. + var overrideDir = Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_DOTNETUP_DATA_DIR"); + if (!string.IsNullOrEmpty(overrideDir)) + { + return overrideDir; + } + if (_dataDirectory is not null) { return _dataDirectory; diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index f822ca4b9865..64c135e03ffa 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; -using OpenTelemetry.Trace; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; diff --git a/src/Installer/dotnetup/Program.cs b/src/Installer/dotnetup/Program.cs index 7a0f4cbb03a9..cc3596f5408b 100644 --- a/src/Installer/dotnetup/Program.cs +++ b/src/Installer/dotnetup/Program.cs @@ -35,7 +35,7 @@ public static int Main(string[] args) DotnetupTelemetry.Instance.RecordException(rootActivity, ex); rootActivity?.SetTag("exit.code", 1); - // Re-throw to preserve original behavior (or handle as appropriate) + // Log the error and return non-zero exit code Console.Error.WriteLine($"Error: {ex.Message}"); #if DEBUG Console.Error.WriteLine(ex.StackTrace); diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index ea635d9a6b51..9ad50f551c73 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; -using OpenTelemetry.Trace; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index bedb2c93116a..ebd5391df0d2 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -31,7 +31,9 @@ public sealed class DotnetupTelemetry : IDisposable GetVersion()); /// - /// Connection string for Application Insights (same as dotnet CLI). + /// Connection string for Application Insights. + /// TODO: Replace with the official SDK CLI Application Insights key before production release. + /// See https://github.com/dotnet/sdk/issues/52785 /// private const string ConnectionString = "InstrumentationKey=04172778-3bc9-4db6-b50f-cafe87756a47;IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=fbd94297-7083-42b8-aaa5-1886192b4272"; @@ -79,6 +81,9 @@ private DotnetupTelemetry() .AddSource("Microsoft.Dotnet.Bootstrapper") .AddSource("Microsoft.Dotnet.Installation"); // Library's ActivitySource + // IMPORTANT: Do NOT add auto-instrumentation (e.g. AddHttpClientInstrumentation) + // without reviewing PII implications. + // Add Azure Monitor exporter builder.AddAzureMonitorTraceExporter(o => { diff --git a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs new file mode 100644 index 000000000000..73185b32b0e3 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.Dotnet.Installation; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Classifies errors as or . +/// +/// +/// Product errors represent issues we can take action on (bugs, server problems, logic errors). +/// User errors represent issues outside our control (invalid input, disk full, network down). +/// This distinction drives quality metrics — product errors count against our success rate, +/// while user errors are tracked separately. +/// +internal static class ErrorCategoryClassifier +{ + /// + /// Classifies an IO error type (from ) as product or user error. + /// + internal static ErrorCategory ClassifyIOError(string errorType) + { + return errorType switch + { + // User environment issues - we can't control these + "DiskFull" => ErrorCategory.User, + "PermissionDenied" => ErrorCategory.User, + "InvalidPath" => ErrorCategory.User, // User specified invalid path + "PathNotFound" => ErrorCategory.User, // User's directory doesn't exist + "NetworkPathNotFound" => ErrorCategory.User, // Network issue + "NetworkNameDeleted" => ErrorCategory.User, // Network issue + "DeviceFailure" => ErrorCategory.User, // Hardware issue + + // Product issues - we should handle these gracefully + "SharingViolation" => ErrorCategory.Product, // Could be our mutex/lock issue + "LockViolation" => ErrorCategory.Product, // Could be our mutex/lock issue + "PathTooLong" => ErrorCategory.Product, // We control the install path + "SemaphoreTimeout" => ErrorCategory.Product, // Could be our concurrency issue + "AlreadyExists" => ErrorCategory.Product, // We should handle existing files gracefully + "FileExists" => ErrorCategory.Product, // We should handle existing files gracefully + "FileNotFound" => ErrorCategory.Product, // Our code referenced missing file + "GeneralFailure" => ErrorCategory.Product, // Unknown IO error + "InvalidParameter" => ErrorCategory.Product, // Our code passed bad params + "IOException" => ErrorCategory.Product, // Generic IO - assume product + + _ => ErrorCategory.Product // Unknown - assume product + }; + } + + /// + /// Classifies a as product or user error. + /// + internal static ErrorCategory ClassifyInstallError(DotnetInstallErrorCode errorCode) + { + return errorCode switch + { + // User errors - bad input or environment issues + DotnetInstallErrorCode.VersionNotFound => ErrorCategory.User, // User typed invalid version + DotnetInstallErrorCode.ReleaseNotFound => ErrorCategory.User, // User requested non-existent release + DotnetInstallErrorCode.InvalidChannel => ErrorCategory.User, // User provided bad channel format + DotnetInstallErrorCode.PermissionDenied => ErrorCategory.User, // User needs to elevate/fix permissions + DotnetInstallErrorCode.DiskFull => ErrorCategory.User, // User's disk is full + DotnetInstallErrorCode.NetworkError => ErrorCategory.User, // User's network issue + + // Product errors - issues we can take action on + DotnetInstallErrorCode.NoMatchingFile => ErrorCategory.Product, // Our manifest/logic issue + DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue + DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue + DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue + DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, // Server unreachable or CDN issue + DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug + DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download + DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, // Our locking mechanism issue + DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, // File system issue with our manifest + DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, // Our manifest is corrupt - we should handle + DotnetInstallErrorCode.Unknown => ErrorCategory.Product, // Unknown = assume product issue + + _ => ErrorCategory.Product // Default to product for new codes + }; + } + + /// + /// Classifies an HTTP status code as product or user error. + /// + internal static ErrorCategory ClassifyHttpError(HttpStatusCode? statusCode) + { + if (!statusCode.HasValue) + { + // No status code usually means network failure - user environment + return ErrorCategory.User; + } + + var code = (int)statusCode.Value; + return code switch + { + >= 500 => ErrorCategory.Product, // 5xx server errors - our infrastructure + 404 => ErrorCategory.User, // Not found - likely user requested invalid resource + 403 => ErrorCategory.User, // Forbidden - user environment/permission issue + 401 => ErrorCategory.User, // Unauthorized - user auth issue + 408 => ErrorCategory.User, // Request timeout - user network + 429 => ErrorCategory.User, // Too many requests - user hitting rate limits + _ => ErrorCategory.Product // Other 4xx - likely our bug (bad request format, etc.) + }; + } +} diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 86bff773afea..adf0cda8fdf5 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -2,10 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Net; -using System.Net.Sockets; -using System.Runtime.InteropServices; -using Microsoft.Dotnet.Installation; namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; @@ -23,7 +19,7 @@ public enum ErrorCategory /// /// User errors - issues caused by user input or environment we can't control. /// Examples: invalid version, disk full, network timeout, permission denied. - /// These are tracked separately and don't count against success rate. + /// These are tracked separately and don't count against primary success rate metrics. /// User } @@ -48,8 +44,19 @@ public sealed record ExceptionErrorInfo( string? ExceptionChain = null); /// -/// Maps exceptions to error info for telemetry. +/// Public façade for error telemetry: maps exceptions to +/// and applies error tags to OpenTelemetry activities. /// +/// +/// Implementation is split across single-responsibility helpers: +/// +/// — exception-type dispatch and enrichment +/// — Product vs User classification +/// — Win32 HResult → telemetry label +/// — PII-safe network exception diagnostics +/// — stack-trace source location and exception chains +/// +/// public static class ErrorCodeMapper { /// @@ -92,536 +99,5 @@ public static void ApplyErrorTags(Activity? activity, ExceptionErrorInfo errorIn /// /// The exception to analyze. /// Error info with type name and contextual details. - public static ExceptionErrorInfo GetErrorInfo(Exception ex) - { - // Unwrap single-inner AggregateExceptions - if (ex is AggregateException { InnerExceptions.Count: 1 } aggEx) - { - return GetErrorInfo(aggEx.InnerExceptions[0]); - } - - // If it's a plain Exception wrapper, use the inner exception for better error type - if (ex.GetType() == typeof(Exception) && ex.InnerException is not null) - { - return GetErrorInfo(ex.InnerException); - } - - // Get common enrichment data - var sourceLocation = GetSafeSourceLocation(ex); - var exceptionChain = GetExceptionChain(ex); - - return ex switch - { - // DotnetInstallException has specific error codes - categorize by error code - // Sanitize the version to prevent PII leakage (user could have typed anything) - // For network-related errors, also check the inner exception for more details - DotnetInstallException installEx => GetInstallExceptionErrorInfo(installEx, sourceLocation, exceptionChain), - - // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues - HttpRequestException httpEx => new ExceptionErrorInfo( - httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", - Category: GetHttpErrorCategory(httpEx.StatusCode), - StatusCode: (int?)httpEx.StatusCode, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - // FileNotFoundException before IOException (it derives from IOException) - // Could be user error (wrong path) or product error (our code referenced wrong file) - // Default to product since we should handle missing files gracefully - FileNotFoundException fnfEx => new ExceptionErrorInfo( - "FileNotFound", - Category: ErrorCategory.Product, - HResult: fnfEx.HResult, - Details: fnfEx.FileName is not null ? "file_specified" : null, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - // Permission denied - user environment issue (needs elevation or different permissions) - UnauthorizedAccessException => new ExceptionErrorInfo( - "PermissionDenied", - Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - // Directory not found - could be user specified bad path - DirectoryNotFoundException => new ExceptionErrorInfo( - "DirectoryNotFound", - Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain), - - // User cancelled the operation - OperationCanceledException => new ExceptionErrorInfo( - "Cancelled", - Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - // Invalid argument - user provided bad input - ArgumentException argEx => new ExceptionErrorInfo( - "InvalidArgument", - Category: ErrorCategory.User, - Details: argEx.ParamName, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - // Invalid operation - usually a bug in our code - InvalidOperationException => new ExceptionErrorInfo( - "InvalidOperation", - Category: ErrorCategory.Product, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - // Not supported - could be user trying unsupported scenario - NotSupportedException => new ExceptionErrorInfo( - "NotSupported", - Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - // Timeout - network/environment issue outside our control - TimeoutException => new ExceptionErrorInfo( - "Timeout", - Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain), - - // Unknown exceptions default to product (fail-safe - we should handle known cases) - _ => new ExceptionErrorInfo( - ex.GetType().Name, - Category: ErrorCategory.Product, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain) - }; - } - - /// - /// Gets the error category for a DotnetInstallErrorCode. - /// - private static ErrorCategory GetInstallErrorCategory(DotnetInstallErrorCode errorCode) - { - return errorCode switch - { - // User errors - bad input or environment issues - DotnetInstallErrorCode.VersionNotFound => ErrorCategory.User, // User typed invalid version - DotnetInstallErrorCode.ReleaseNotFound => ErrorCategory.User, // User requested non-existent release - DotnetInstallErrorCode.InvalidChannel => ErrorCategory.User, // User provided bad channel format - DotnetInstallErrorCode.PermissionDenied => ErrorCategory.User, // User needs to elevate/fix permissions - DotnetInstallErrorCode.DiskFull => ErrorCategory.User, // User's disk is full - DotnetInstallErrorCode.NetworkError => ErrorCategory.User, // User's network issue - - // Product errors - issues we can take action on - DotnetInstallErrorCode.NoMatchingFile => ErrorCategory.Product, // Our manifest/logic issue - DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue - DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue - DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue - DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, // Server unreachable or CDN issue - DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug - DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download - DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, // Our locking mechanism issue - DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, // File system issue with our manifest - DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, // Our manifest is corrupt - we should handle - DotnetInstallErrorCode.Unknown => ErrorCategory.Product, // Unknown = assume product issue - - _ => ErrorCategory.Product // Default to product for new codes - }; - } - - /// - /// Gets error info for a DotnetInstallException, enriching with inner exception details - /// for network-related errors. - /// - private static ExceptionErrorInfo GetInstallExceptionErrorInfo( - DotnetInstallException installEx, - string? sourceLocation, - string? exceptionChain) - { - var errorCode = installEx.ErrorCode; - var baseCategory = GetInstallErrorCategory(errorCode); - var details = installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null; - int? httpStatus = null; - - // For network-related errors, check the inner exception to better categorize - // and extract additional diagnostic info - if (IsNetworkRelatedErrorCode(errorCode) && installEx.InnerException is not null) - { - var (refinedCategory, innerHttpStatus, innerDetails) = AnalyzeNetworkException(installEx.InnerException); - baseCategory = refinedCategory; - httpStatus = innerHttpStatus; - - // Combine details: version + inner exception info - if (innerDetails is not null) - { - details = details is not null ? $"{details};{innerDetails}" : innerDetails; - } - } - - return new ExceptionErrorInfo( - errorCode.ToString(), - Category: baseCategory, - StatusCode: httpStatus, - Details: details, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain); - } - - /// - /// Checks if the error code is related to network operations. - /// - private static bool IsNetworkRelatedErrorCode(DotnetInstallErrorCode errorCode) - { - return errorCode is - DotnetInstallErrorCode.ManifestFetchFailed or - DotnetInstallErrorCode.DownloadFailed or - DotnetInstallErrorCode.NetworkError; - } - - /// - /// Analyzes a network-related inner exception to determine the category and extract details. - /// - private static (ErrorCategory Category, int? HttpStatus, string? Details) AnalyzeNetworkException(Exception inner) - { - // Walk the exception chain to find HttpRequestException or SocketException - // Look for the most specific info we can find - HttpRequestException? foundHttpEx = null; - SocketException? foundSocketEx = null; - - var current = inner; - while (current is not null) - { - if (current is HttpRequestException httpEx && foundHttpEx is null) - { - foundHttpEx = httpEx; - } - - if (current is SocketException socketEx && foundSocketEx is null) - { - foundSocketEx = socketEx; - } - - current = current.InnerException; - } - - // Prefer socket-level info if available (more specific) - if (foundSocketEx is not null) - { - var socketErrorName = foundSocketEx.SocketErrorCode.ToString().ToLowerInvariant(); - return (ErrorCategory.User, null, $"socket_{socketErrorName}"); - } - - // Then HTTP-level info - if (foundHttpEx is not null) - { - var category = GetHttpErrorCategory(foundHttpEx.StatusCode); - var httpStatus = (int?)foundHttpEx.StatusCode; - - string? details = null; - if (foundHttpEx.StatusCode.HasValue) - { - details = $"http_{(int)foundHttpEx.StatusCode}"; - } - else if (foundHttpEx.HttpRequestError != HttpRequestError.Unknown) - { - // .NET 7+ has HttpRequestError enum for non-HTTP failures - details = $"request_error_{foundHttpEx.HttpRequestError.ToString().ToLowerInvariant()}"; - } - - return (category, httpStatus, details); - } - - // Couldn't determine from inner exception - use default Product category - // but mark as unknown network error - return (ErrorCategory.Product, null, "network_unknown"); - } - - /// - /// Gets the error category for an HTTP status code. - /// - private static ErrorCategory GetHttpErrorCategory(HttpStatusCode? statusCode) - { - if (!statusCode.HasValue) - { - // No status code usually means network failure - user environment - return ErrorCategory.User; - } - - var code = (int)statusCode.Value; - return code switch - { - >= 500 => ErrorCategory.Product, // 5xx server errors - our infrastructure - 404 => ErrorCategory.User, // Not found - likely user requested invalid resource - 403 => ErrorCategory.User, // Forbidden - user environment/permission issue - 401 => ErrorCategory.User, // Unauthorized - user auth issue - 408 => ErrorCategory.User, // Request timeout - user network - 429 => ErrorCategory.User, // Too many requests - user hitting rate limits - _ => ErrorCategory.Product // Other 4xx - likely our bug (bad request format, etc.) - }; - } - - private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain) - { - string errorType; - string? details; - ErrorCategory category; - - // On Windows, use HResult to derive error type - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && ioEx.HResult != 0) - { - // Extract the Win32 error code from HResult (lower 16 bits) - var win32ErrorCode = ioEx.HResult & 0xFFFF; - - // Derive a short error type from the HResult - errorType = GetWindowsErrorType(ioEx.HResult); - category = GetIOErrorCategory(errorType); - // Don't use win32Ex.Message - it can contain paths/PII - // Just use the error code for details - details = $"win32_error_{win32ErrorCode}"; - } - else - { - // On non-Windows or if no HResult, use our mapping - (errorType, details) = GetErrorTypeFromHResult(ioEx.HResult); - category = GetIOErrorCategory(errorType); - } - - return new ExceptionErrorInfo( - errorType, - Category: category, - HResult: ioEx.HResult, - Details: details, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain); - } - - /// - /// Gets the error category for an IO error type. - /// - private static ErrorCategory GetIOErrorCategory(string errorType) - { - return errorType switch - { - // User environment issues - we can't control these - "DiskFull" => ErrorCategory.User, - "PermissionDenied" => ErrorCategory.User, - "InvalidPath" => ErrorCategory.User, // User specified invalid path - "PathNotFound" => ErrorCategory.User, // User's directory doesn't exist - "NetworkPathNotFound" => ErrorCategory.User, // Network issue - "NetworkNameDeleted" => ErrorCategory.User, // Network issue - "DeviceFailure" => ErrorCategory.User, // Hardware issue - - // Product issues - we should handle these gracefully - "SharingViolation" => ErrorCategory.Product, // Could be our mutex/lock issue - "LockViolation" => ErrorCategory.Product, // Could be our mutex/lock issue - "PathTooLong" => ErrorCategory.Product, // We control the install path - "SemaphoreTimeout" => ErrorCategory.Product, // Could be our concurrency issue - "AlreadyExists" => ErrorCategory.Product, // We should handle existing files gracefully - "FileExists" => ErrorCategory.Product, // We should handle existing files gracefully - "FileNotFound" => ErrorCategory.Product, // Our code referenced missing file - "GeneralFailure" => ErrorCategory.Product, // Unknown IO error - "InvalidParameter" => ErrorCategory.Product, // Our code passed bad params - "IOException" => ErrorCategory.Product, // Generic IO - assume product - - _ => ErrorCategory.Product // Unknown - assume product - }; - } - - /// - /// Gets a short error type name from a Windows HResult. - /// - private static string GetWindowsErrorType(int hResult) - { - return hResult switch - { - unchecked((int)0x80070070) or unchecked((int)0x80070027) => "DiskFull", - unchecked((int)0x80070005) => "PermissionDenied", - unchecked((int)0x80070020) => "SharingViolation", - unchecked((int)0x80070021) => "LockViolation", - unchecked((int)0x800700CE) => "PathTooLong", - unchecked((int)0x8007007B) => "InvalidPath", - unchecked((int)0x80070003) => "PathNotFound", - unchecked((int)0x80070002) => "FileNotFound", - unchecked((int)0x800700B7) => "AlreadyExists", - unchecked((int)0x80070050) => "FileExists", - unchecked((int)0x80070035) => "NetworkPathNotFound", - unchecked((int)0x80070033) => "NetworkNameDeleted", - unchecked((int)0x80004005) => "GeneralFailure", - unchecked((int)0x8007001F) => "DeviceFailure", - unchecked((int)0x80070057) => "InvalidParameter", - unchecked((int)0x80070079) => "SemaphoreTimeout", - _ => "IOException" - }; - } - - /// - /// Gets error type and details from HResult for non-Windows platforms. - /// - private static (string errorType, string? details) GetErrorTypeFromHResult(int hResult) - { - return hResult switch - { - // Disk/storage errors - unchecked((int)0x80070070) => ("DiskFull", "ERROR_DISK_FULL"), - unchecked((int)0x80070027) => ("DiskFull", "ERROR_HANDLE_DISK_FULL"), - unchecked((int)0x80070079) => ("SemaphoreTimeout", "ERROR_SEM_TIMEOUT"), - - // Permission errors - unchecked((int)0x80070005) => ("PermissionDenied", "ERROR_ACCESS_DENIED"), - unchecked((int)0x80070020) => ("SharingViolation", "ERROR_SHARING_VIOLATION"), - unchecked((int)0x80070021) => ("LockViolation", "ERROR_LOCK_VIOLATION"), - - // Path errors - unchecked((int)0x800700CE) => ("PathTooLong", "ERROR_FILENAME_EXCED_RANGE"), - unchecked((int)0x8007007B) => ("InvalidPath", "ERROR_INVALID_NAME"), - unchecked((int)0x80070003) => ("PathNotFound", "ERROR_PATH_NOT_FOUND"), - unchecked((int)0x80070002) => ("FileNotFound", "ERROR_FILE_NOT_FOUND"), - - // File/directory existence errors - unchecked((int)0x800700B7) => ("AlreadyExists", "ERROR_ALREADY_EXISTS"), - unchecked((int)0x80070050) => ("FileExists", "ERROR_FILE_EXISTS"), - - // Network errors - unchecked((int)0x80070035) => ("NetworkPathNotFound", "ERROR_BAD_NETPATH"), - unchecked((int)0x80070033) => ("NetworkNameDeleted", "ERROR_NETNAME_DELETED"), - unchecked((int)0x80004005) => ("GeneralFailure", "E_FAIL"), - - // Device/hardware errors - unchecked((int)0x8007001F) => ("DeviceFailure", "ERROR_GEN_FAILURE"), - unchecked((int)0x80070057) => ("InvalidParameter", "ERROR_INVALID_PARAMETER"), - - // Default: include raw HResult for debugging - _ => ("IOException", hResult != 0 ? $"0x{hResult:X8}" : null) - }; - } - - /// - /// Gets a safe source location from the stack trace - finds the first frame from our assemblies. - /// This is typically the code in dotnetup that called into BCL/external code that threw. - /// No file paths that could contain user info. Line numbers from our code are included as they are not PII. - /// - private static string? GetSafeSourceLocation(Exception ex) - { - try - { - var stackTrace = new StackTrace(ex, fNeedFileInfo: true); - var frames = stackTrace.GetFrames(); - - if (frames == null || frames.Length == 0) - { - return null; - } - - string? throwSite = null; - - // Walk the stack from throw site upward, looking for the first frame in our code. - // This finds the dotnetup code that called into BCL/external code that threw. - foreach (var frame in frames) - { - var methodInfo = DiagnosticMethodInfo.Create(frame); - if (methodInfo == null) continue; - - // DiagnosticMethodInfo provides DeclaringTypeName which includes the full type name - var declaringType = methodInfo.DeclaringTypeName; - if (string.IsNullOrEmpty(declaringType)) continue; - - // Capture the first frame as the throw site (fallback) - if (throwSite == null) - { - var throwTypeName = ExtractTypeName(declaringType); - throwSite = $"[BCL]{throwTypeName}.{methodInfo.Name}"; - } - - // Check if it's from our assemblies by looking at the namespace prefix - if (IsOwnedNamespace(declaringType)) - { - // Extract just the type name (last part after the last dot, before any generic params) - var typeName = ExtractTypeName(declaringType); - - // Include line number for our code (not PII), but never file paths - // Also include commit SHA so line numbers can be correlated to source - var lineNumber = frame.GetFileLineNumber(); - var location = $"{typeName}.{methodInfo.Name}"; - if (lineNumber > 0) - { - location += $":{lineNumber}"; - } - return location; - } - } - - // If we didn't find our code, return the throw site as a fallback - // This code is managed by dotnetup and not the library so we expect the throwsite to only be our own dependent code we call into - return throwSite; - } - catch - { - // Never fail telemetry due to stack trace parsing - return null; - } - } - - /// - /// Checks if a type name belongs to one of our owned namespaces. - /// - private static bool IsOwnedNamespace(string declaringType) - { - return declaringType.StartsWith("Microsoft.DotNet.Tools.Bootstrapper", StringComparison.Ordinal) || - declaringType.StartsWith("Microsoft.Dotnet.Installation", StringComparison.Ordinal); - } - - /// - /// Extracts just the type name from a fully qualified type name. - /// - private static string ExtractTypeName(string fullTypeName) - { - var typeName = fullTypeName; - var lastDot = typeName.LastIndexOf('.'); - if (lastDot >= 0) - { - typeName = typeName.Substring(lastDot + 1); - } - // Remove generic arity if present (e.g., "List`1" -> "List") - var genericMarker = typeName.IndexOf('`'); - if (genericMarker >= 0) - { - typeName = typeName.Substring(0, genericMarker); - } - return typeName; - } - - /// - /// Gets the exception type chain for wrapped exceptions. - /// Example: "HttpRequestException->SocketException" - /// - private static string? GetExceptionChain(Exception ex) - { - if (ex.InnerException == null) - { - return null; - } - - try - { - var types = new List { ex.GetType().Name }; - var inner = ex.InnerException; - - // Limit depth to prevent infinite loops and overly long strings - const int maxDepth = 5; - var depth = 0; - - while (inner != null && depth < maxDepth) - { - types.Add(inner.GetType().Name); - inner = inner.InnerException; - depth++; - } - - return string.Join("->", types); - } - catch - { - return null; - } - } + public static ExceptionErrorInfo GetErrorInfo(Exception ex) => ExceptionErrorMapper.Map(ex); } diff --git a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs new file mode 100644 index 000000000000..34d05fe12e1b --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Dotnet.Installation; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Maps exceptions to for telemetry. +/// Each exception type is classified with a telemetry-safe error type, +/// a , and optional PII-free details. +/// +internal static class ExceptionErrorMapper +{ + /// + /// Builds an from the given exception, + /// enriching it with source location and exception chain metadata. + /// + internal static ExceptionErrorInfo Map(Exception ex) + { + // Unwrap single-inner AggregateExceptions + if (ex is AggregateException { InnerExceptions.Count: 1 } aggEx) + { + return Map(aggEx.InnerExceptions[0]); + } + + // If it's a plain Exception wrapper, use the inner exception for better error type + if (ex.GetType() == typeof(Exception) && ex.InnerException is not null) + { + return Map(ex.InnerException); + } + + // Get common enrichment data + var sourceLocation = ExceptionInspector.GetSafeSourceLocation(ex); + var exceptionChain = ExceptionInspector.GetExceptionChain(ex); + + return ex switch + { + // DotnetInstallException has specific error codes — categorize by error code. + // Sanitize the version to prevent PII leakage (user could have typed anything). + // For network-related errors, also check the inner exception for more details. + DotnetInstallException installEx => MapInstallException(installEx, sourceLocation, exceptionChain), + + // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues + HttpRequestException httpEx => new ExceptionErrorInfo( + httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", + Category: ErrorCategoryClassifier.ClassifyHttpError(httpEx.StatusCode), + StatusCode: (int?)httpEx.StatusCode, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + // FileNotFoundException before IOException (it derives from IOException). + // Could be user error (wrong path) or product error (our code referenced wrong file). + // Default to product since we should handle missing files gracefully. + FileNotFoundException fnfEx => new ExceptionErrorInfo( + "FileNotFound", + Category: ErrorCategory.Product, + HResult: fnfEx.HResult, + Details: fnfEx.FileName is not null ? "file_specified" : null, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + // Permission denied — user environment issue (needs elevation or different permissions) + UnauthorizedAccessException => new ExceptionErrorInfo( + "PermissionDenied", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + // Directory not found — could be user specified bad path + DirectoryNotFoundException => new ExceptionErrorInfo( + "DirectoryNotFound", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain), + + // User cancelled the operation + OperationCanceledException => new ExceptionErrorInfo( + "Cancelled", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + // Invalid argument — user provided bad input + ArgumentException argEx => new ExceptionErrorInfo( + "InvalidArgument", + Category: ErrorCategory.User, + Details: argEx.ParamName, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + // Invalid operation — usually a bug in our code + InvalidOperationException => new ExceptionErrorInfo( + "InvalidOperation", + Category: ErrorCategory.Product, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + // Not supported — could be user trying unsupported scenario + NotSupportedException => new ExceptionErrorInfo( + "NotSupported", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + // Timeout — network/environment issue outside our control + TimeoutException => new ExceptionErrorInfo( + "Timeout", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain), + + // Unknown exceptions default to product (fail-safe — we should handle known cases) + _ => new ExceptionErrorInfo( + ex.GetType().Name, + Category: ErrorCategory.Product, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain) + }; + } + + /// + /// Maps a , enriching with inner exception details + /// for network-related errors. + /// + private static ExceptionErrorInfo MapInstallException( + DotnetInstallException installEx, + string? sourceLocation, + string? exceptionChain) + { + var errorCode = installEx.ErrorCode; + var baseCategory = ErrorCategoryClassifier.ClassifyInstallError(errorCode); + var details = installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null; + int? httpStatus = null; + + // For network-related errors, check the inner exception to better categorize + // and extract additional diagnostic info + if (NetworkErrorAnalyzer.IsNetworkRelatedErrorCode(errorCode) && installEx.InnerException is not null) + { + var (refinedCategory, innerHttpStatus, innerDetails) = NetworkErrorAnalyzer.AnalyzeNetworkException(installEx.InnerException); + baseCategory = refinedCategory; + httpStatus = innerHttpStatus; + + // Combine details: version + inner exception info + if (innerDetails is not null) + { + details = details is not null ? $"{details};{innerDetails}" : innerDetails; + } + } + + return new ExceptionErrorInfo( + errorCode.ToString(), + Category: baseCategory, + StatusCode: httpStatus, + Details: details, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain); + } + + /// + /// Maps a generic using its HResult. + /// + private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain) + { + var (errorType, details) = HResultMapper.GetErrorTypeFromHResult(ioEx.HResult); + var category = ErrorCategoryClassifier.ClassifyIOError(errorType); + + return new ExceptionErrorInfo( + errorType, + Category: category, + HResult: ioEx.HResult, + Details: details, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain); + } +} diff --git a/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs b/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs new file mode 100644 index 000000000000..c71787bf846d --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Extracts PII-safe diagnostic metadata from exceptions for telemetry: +/// source location (stack-trace inspection) and exception-type chains. +/// +/// +/// All output is designed to be safe for telemetry ingestion: +/// +/// Source locations contain only type/method names and line numbers from our +/// assemblies — never file paths, user names, or arguments. +/// Exception chains contain only CLR type names, which are stable and non-PII. +/// +/// Uses (.NET 8+) for AOT-compatible stack frame inspection. +/// +internal static class ExceptionInspector +{ + /// + /// Gets a safe source location from the stack trace — finds the first frame from our assemblies. + /// This is typically the code in dotnetup that called into BCL/external code that threw. + /// No file paths that could contain user info. Line numbers from our code are included as they are not PII. + /// + internal static string? GetSafeSourceLocation(Exception ex) + { + try + { + var stackTrace = new StackTrace(ex, fNeedFileInfo: true); + var frames = stackTrace.GetFrames(); + + if (frames == null || frames.Length == 0) + { + return null; + } + + string? throwSite = null; + + // Walk the stack from throw site upward, looking for the first frame in our code. + // This finds the dotnetup code that called into BCL/external code that threw. + foreach (var frame in frames) + { + var methodInfo = DiagnosticMethodInfo.Create(frame); + if (methodInfo == null) continue; + + // DiagnosticMethodInfo provides DeclaringTypeName which includes the full type name + var declaringType = methodInfo.DeclaringTypeName; + if (string.IsNullOrEmpty(declaringType)) continue; + + // Capture the first frame as the throw site (fallback) + if (throwSite == null) + { + var throwTypeName = ExtractTypeName(declaringType); + throwSite = $"[BCL]{throwTypeName}.{methodInfo.Name}"; + } + + // Check if it's from our assemblies by looking at the namespace prefix + if (IsOwnedNamespace(declaringType)) + { + // Extract just the type name (last part after the last dot, before any generic params) + var typeName = ExtractTypeName(declaringType); + + // Include line number for our code (not PII), but never file paths + var lineNumber = frame.GetFileLineNumber(); + var location = $"{typeName}.{methodInfo.Name}"; + if (lineNumber > 0) + { + location += $":{lineNumber}"; + } + return location; + } + } + + // If we didn't find our code, return the throw site as a fallback. + // The throw site is from BCL or our NuGet dependencies (e.g., System.IO, System.Net) + return throwSite; + } + catch + { + // Never fail telemetry due to stack trace parsing + return null; + } + } + + /// + /// Gets the exception type chain for wrapped exceptions. + /// Example: "HttpRequestException->SocketException" + /// + internal static string? GetExceptionChain(Exception ex) + { + if (ex.InnerException == null) + { + return null; + } + + try + { + var types = new List { ex.GetType().Name }; + var inner = ex.InnerException; + + // Limit depth to prevent infinite loops and overly long strings + const int maxDepth = 5; + var depth = 0; + + while (inner != null && depth < maxDepth) + { + types.Add(inner.GetType().Name); + inner = inner.InnerException; + depth++; + } + + return string.Join("->", types); + } + catch + { + return null; + } + } + + /// + /// Checks if a type name belongs to one of our owned namespaces. + /// + private static bool IsOwnedNamespace(string declaringType) + { + return declaringType.StartsWith("Microsoft.DotNet.Tools.Bootstrapper", StringComparison.Ordinal) || + declaringType.StartsWith("Microsoft.Dotnet.Installation", StringComparison.Ordinal); + } + + /// + /// Extracts just the type name from a fully qualified type name. + /// + private static string ExtractTypeName(string fullTypeName) + { + var typeName = fullTypeName; + var lastDot = typeName.LastIndexOf('.'); + if (lastDot >= 0) + { + typeName = typeName.Substring(lastDot + 1); + } + // Remove generic arity if present (e.g., "List`1" -> "List") + var genericMarker = typeName.IndexOf('`'); + if (genericMarker >= 0) + { + typeName = typeName.Substring(0, genericMarker); + } + return typeName; + } +} diff --git a/src/Installer/dotnetup/Telemetry/HResultMapper.cs b/src/Installer/dotnetup/Telemetry/HResultMapper.cs new file mode 100644 index 000000000000..ef620226eda7 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/HResultMapper.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Maps Win32 HResult codes to short, telemetry-safe error type labels. +/// +/// +/// This custom mapping is necessary because .NET does not expose a public API for converting +/// HResult codes to symbolic names suitable for telemetry. The alternatives were considered: +/// +/// - new Win32Exception(errorCode).Message returns localized, human-readable strings +/// (e.g., "Access is denied") that vary by locale, may contain file paths (PII risk), +/// and are not machine-parseable. +/// +/// - Marshal.GetExceptionForHR(hr) returns COMException for most HRs, which +/// loses the specific error code information. +/// +/// - Marshal.GetPInvokeErrorMessage(errorCode) produces localized messages, same issues. +/// +/// - The runtime's Exception::GetHRSymbolicName() (in ex.cpp) does map HRs to symbolic +/// names but is internal C++ code, not exposed to managed code. +/// +/// The runtime does throw specific IOException subclasses for some Win32 errors +/// (e.g., FileNotFoundException for ERROR_FILE_NOT_FOUND, DirectoryNotFoundException +/// for ERROR_PATH_NOT_FOUND). These are handled upstream in +/// before reaching the HResult mapper. However, many Win32 errors (sharing violation, disk full, +/// lock violation, etc.) produce a plain IOException with only the HResult set to +/// 0x80070000 | win32ErrorCode, not COR_E_IO (0x80131620). For these, the HResult +/// is the only way to determine the specific failure. +/// +internal static class HResultMapper +{ + /// + /// Gets a short, telemetry-safe error type label and optional Win32 symbolic name from an HResult. + /// + /// The HResult from an . + /// + /// A tuple of (errorType, details) where errorType is a short label like "DiskFull" + /// and details is the Win32 symbolic name like "ERROR_DISK_FULL" (or a hex string for unknown codes). + /// + internal static (string errorType, string? details) GetErrorTypeFromHResult(int hResult) + { + return hResult switch + { + // Disk/storage errors + unchecked((int)0x80070070) => ("DiskFull", "ERROR_DISK_FULL"), + unchecked((int)0x80070027) => ("DiskFull", "ERROR_HANDLE_DISK_FULL"), + + // Semaphore/concurrency errors + unchecked((int)0x80070079) => ("SemaphoreTimeout", "ERROR_SEM_TIMEOUT"), + + // Permission errors + unchecked((int)0x80070005) => ("PermissionDenied", "ERROR_ACCESS_DENIED"), + unchecked((int)0x80070020) => ("SharingViolation", "ERROR_SHARING_VIOLATION"), + unchecked((int)0x80070021) => ("LockViolation", "ERROR_LOCK_VIOLATION"), + + // Path errors + unchecked((int)0x800700CE) => ("PathTooLong", "ERROR_FILENAME_EXCED_RANGE"), + unchecked((int)0x8007007B) => ("InvalidPath", "ERROR_INVALID_NAME"), + unchecked((int)0x80070003) => ("PathNotFound", "ERROR_PATH_NOT_FOUND"), + unchecked((int)0x80070002) => ("FileNotFound", "ERROR_FILE_NOT_FOUND"), + + // File/directory existence errors + unchecked((int)0x800700B7) => ("AlreadyExists", "ERROR_ALREADY_EXISTS"), + unchecked((int)0x80070050) => ("FileExists", "ERROR_FILE_EXISTS"), + + // Network errors + unchecked((int)0x80070035) => ("NetworkPathNotFound", "ERROR_BAD_NETPATH"), + unchecked((int)0x80070033) => ("NetworkNameDeleted", "ERROR_NETNAME_DELETED"), + unchecked((int)0x80004005) => ("GeneralFailure", "E_FAIL"), + + // Device/hardware errors + unchecked((int)0x8007001F) => ("DeviceFailure", "ERROR_GEN_FAILURE"), + unchecked((int)0x80070057) => ("InvalidParameter", "ERROR_INVALID_PARAMETER"), + + // Default: include raw HResult for debugging + _ => ("IOException", hResult != 0 ? $"0x{hResult:X8}" : null) + }; + } +} diff --git a/src/Installer/dotnetup/Telemetry/NetworkErrorAnalyzer.cs b/src/Installer/dotnetup/Telemetry/NetworkErrorAnalyzer.cs new file mode 100644 index 000000000000..8a2de224afea --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/NetworkErrorAnalyzer.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using Microsoft.Dotnet.Installation; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Analyzes network-related exceptions to extract telemetry-safe diagnostic info +/// (HTTP status codes, socket error codes) without leaking PII. +/// +internal static class NetworkErrorAnalyzer +{ + /// + /// Checks if a is related to network operations. + /// + internal static bool IsNetworkRelatedErrorCode(DotnetInstallErrorCode errorCode) + { + return errorCode is + DotnetInstallErrorCode.ManifestFetchFailed or + DotnetInstallErrorCode.DownloadFailed or + DotnetInstallErrorCode.NetworkError; + } + + /// + /// Walks the exception chain to find HTTP and socket-level diagnostic info, + /// then determines the error category accordingly. + /// + /// + /// A tuple of (Category, HttpStatus, Details) with PII-safe diagnostic information. + /// + internal static (ErrorCategory Category, int? HttpStatus, string? Details) AnalyzeNetworkException(Exception inner) + { + // Walk the exception chain to find HttpRequestException or SocketException. + // Look for the most specific info we can find. + HttpRequestException? foundHttpEx = null; + SocketException? foundSocketEx = null; + + var current = inner; + while (current is not null) + { + if (current is HttpRequestException httpEx && foundHttpEx is null) + { + foundHttpEx = httpEx; + } + + if (current is SocketException socketEx && foundSocketEx is null) + { + foundSocketEx = socketEx; + } + + current = current.InnerException; + } + + // Prefer socket-level info if available (more specific) + if (foundSocketEx is not null) + { + var socketErrorName = foundSocketEx.SocketErrorCode.ToString().ToLowerInvariant(); + return (ErrorCategory.User, null, $"socket_{socketErrorName}"); + } + + // Then HTTP-level info + if (foundHttpEx is not null) + { + var category = ErrorCategoryClassifier.ClassifyHttpError(foundHttpEx.StatusCode); + var httpStatus = (int?)foundHttpEx.StatusCode; + + string? details = null; + if (foundHttpEx.StatusCode.HasValue) + { + details = $"http_{(int)foundHttpEx.StatusCode}"; + } + else if (foundHttpEx.HttpRequestError != HttpRequestError.Unknown) + { + // .NET 7+ has HttpRequestError enum for non-HTTP failures + details = $"request_error_{foundHttpEx.HttpRequestError.ToString().ToLowerInvariant()}"; + } + + return (category, httpStatus, details); + } + + // Couldn't determine from inner exception - use default Product category + // but mark as unknown network error + return (ErrorCategory.Product, null, "network_unknown"); + } +} diff --git a/src/Installer/dotnetup/docs/telemetry-notice.txt b/src/Installer/dotnetup/docs/telemetry-notice.txt index 31f2e26c676d..4202718c7e34 100644 --- a/src/Installer/dotnetup/docs/telemetry-notice.txt +++ b/src/Installer/dotnetup/docs/telemetry-notice.txt @@ -43,16 +43,17 @@ Data collected includes: ## Crash Exception Telemetry -If dotnetup crashes, it collects the name of the exception and stack trace of -the dotnetup code and dotnetup invoked code only. The collected data contains the exception type and a -sanitized stack trace that includes method names and line numbers from dotnetup -source code, but NOT file paths. +If dotnetup crashes, it collects the exception type, error category, and a +source location identifying the dotnetup or dotnetup dependency method where the error +originated. The source location includes the type name, method name, and line +number from dotnetup source code. If the exception wraps +inner exceptions, the exception type chain is also recorded. Example of collected crash data: ErrorType: IOException Category: Product - SourceLocation: Downloader.DownloadAsync:145@abc123d + SourceLocation: Downloader.DownloadAsync:145 ExceptionChain: IOException->SocketException ## CI and LLM Agent Detection diff --git a/test/dotnetup.Tests/DnupE2Etest.cs b/test/dotnetup.Tests/DnupE2Etest.cs index dafa35211113..ea674e0916c3 100644 --- a/test/dotnetup.Tests/DnupE2Etest.cs +++ b/test/dotnetup.Tests/DnupE2Etest.cs @@ -571,6 +571,4 @@ public void RuntimeInstall_AfterSdkInstall_BehavesCorrectly(string componentSpec output.Should().NotContain("Downloading", "Should not download when files already exist from SDK"); } } - - /// } diff --git a/test/dotnetup.Tests/InfoCommandTests.cs b/test/dotnetup.Tests/InfoCommandTests.cs index dd53062b1ab6..05d339847506 100644 --- a/test/dotnetup.Tests/InfoCommandTests.cs +++ b/test/dotnetup.Tests/InfoCommandTests.cs @@ -2,6 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.Info; + namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public class InfoCommandTests diff --git a/test/dotnetup.Tests/InstallTelemetryTests.cs b/test/dotnetup.Tests/InstallTelemetryTests.cs index 49cf76485d0a..7a2471d7fb61 100644 --- a/test/dotnetup.Tests/InstallTelemetryTests.cs +++ b/test/dotnetup.Tests/InstallTelemetryTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Runtime.InteropServices; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Xunit; diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index c3480054b5c1..0c7a24daf21c 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Runtime.InteropServices; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Xunit; @@ -360,21 +359,36 @@ public void NonUpdatingProgressTarget_SetsCallerTagOnActivity() } } -public class FirstRunNoticeTests +public class FirstRunNoticeTests : IDisposable { private const string NoLogoEnvVar = "DOTNET_NOLOGO"; + private const string DataDirEnvVar = "DOTNET_TESTHOOK_DOTNETUP_DATA_DIR"; - [Fact] - public void IsFirstRun_ReturnsTrueWhenSentinelDoesNotExist() + private readonly string _tempDir; + private readonly string? _originalDataDir; + + public FirstRunNoticeTests() { - // Clean up any existing sentinel for this test - var sentinelPath = DotnetupPaths.TelemetrySentinelPath; + _tempDir = Path.Combine(Path.GetTempPath(), $"dotnetup-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); - if (!string.IsNullOrEmpty(sentinelPath) && File.Exists(sentinelPath)) - { - File.Delete(sentinelPath); - } + // Redirect DotnetupPaths.DataDirectory to the temp dir so tests + // never touch the real user profile. + _originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvVar); + Environment.SetEnvironmentVariable(DataDirEnvVar, _tempDir); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(DataDirEnvVar, _originalDataDir); + try { Directory.Delete(_tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + [Fact] + public void IsFirstRun_ReturnsTrueWhenSentinelDoesNotExist() + { Assert.True(FirstRunNotice.IsFirstRun()); } @@ -387,15 +401,9 @@ public void ShowIfFirstRun_CreatesSentinelFile() try { - // Clean up any existing sentinel for this test var sentinelPath = DotnetupPaths.TelemetrySentinelPath; Assert.NotNull(sentinelPath); - if (File.Exists(sentinelPath)) - { - File.Delete(sentinelPath); - } - // Simulate first run with telemetry enabled FirstRunNotice.ShowIfFirstRun(telemetryEnabled: true); @@ -414,19 +422,12 @@ public void ShowIfFirstRun_CreatesSentinelFile() [Fact] public void ShowIfFirstRun_DoesNotCreateSentinel_WhenTelemetryDisabled() { - // Clean up any existing sentinel for this test - var sentinelPath = DotnetupPaths.TelemetrySentinelPath; - Assert.NotNull(sentinelPath); - - if (File.Exists(sentinelPath)) - { - File.Delete(sentinelPath); - } - // Simulate first run with telemetry disabled FirstRunNotice.ShowIfFirstRun(telemetryEnabled: false); // Sentinel should NOT be created (user has opted out) + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; + Assert.NotNull(sentinelPath); Assert.False(File.Exists(sentinelPath)); } } From a3bf658b88fd5314de048c7833f8a5b897c21530 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 19 Feb 2026 15:07:32 -0800 Subject: [PATCH 42/59] don't assume sdk install anymore, reduce dead code / duplicate impls --- .../Internal/DotnetArchiveDownloader.cs | 3 +- .../Internal/UrlSanitizer.cs | 59 +++++++++++++++++++ src/Installer/dotnetup/CommandBase.cs | 8 +-- .../Commands/Shared/InstallWorkflow.cs | 4 +- .../dotnetup/NonUpdatingProgressTarget.cs | 11 +--- .../dotnetup/SpectreProgressTarget.cs | 11 +--- .../dotnetup/Telemetry/UrlSanitizer.cs | 55 +++-------------- .../dotnetup/Telemetry/dotnetup-workbook.json | 12 ++-- 8 files changed, 81 insertions(+), 82 deletions(-) create mode 100644 src/Installer/Microsoft.Dotnet.Installation/Internal/UrlSanitizer.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index ccfe68d0b159..abedcc8c778d 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -238,8 +238,7 @@ public void DownloadArchiveWithVerification( component: installRequest.Component.ToString()); } - // Set download URL for telemetry - Activity.Current?.SetTag("download.url", downloadUrl); + Activity.Current?.SetTag("download.url_domain", UrlSanitizer.SanitizeDomain(downloadUrl)); // Check the cache first string? cachedFilePath = _downloadCache.GetCachedFilePath(downloadUrl); diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/UrlSanitizer.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/UrlSanitizer.cs new file mode 100644 index 000000000000..daacdeac408c --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/UrlSanitizer.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Dotnet.Installation.Internal; + +/// +/// Sanitizes URLs for telemetry to prevent PII leakage. +/// Only known safe domains are reported; unknown domains are replaced with "unknown". +/// +public static class UrlSanitizer +{ + /// + /// Known .NET download domains that are safe to report in telemetry. + /// Unknown domains are reported as "unknown" to prevent PII leakage + /// from custom/private mirrors. + /// + /// + /// See: https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/Acquisition/GlobalInstallerResolver.ts + /// + public static readonly IReadOnlyList KnownDownloadDomains = + [ + "download.visualstudio.microsoft.com", + "builds.dotnet.microsoft.com", + "ci.dot.net", + "dotnetcli.blob.core.windows.net", + "dotnetcli.azureedge.net" // Legacy CDN, may still be referenced + ]; + + /// + /// Extracts and sanitizes the domain from a URL for telemetry purposes. + /// Returns "unknown" for unrecognized domains to prevent PII leakage from custom mirrors. + /// + /// The URL to extract the domain from. + /// The domain if known, or "unknown" for unrecognized/private domains. + public static string SanitizeDomain(string? url) + { + if (string.IsNullOrEmpty(url)) + { + return "unknown"; + } + + try + { + var host = new Uri(url).Host; + foreach (var knownDomain in KnownDownloadDomains) + { + if (host.Equals(knownDomain, StringComparison.OrdinalIgnoreCase)) + { + return host; + } + } + return "unknown"; + } + catch + { + return "unknown"; + } + } +} diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index fde5e0b60651..b0c4ff300362 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -122,21 +122,21 @@ protected void RecordFailure(string reason, string? message = null, string categ protected void RecordRequestedVersion(string? versionOrChannel) { var sanitized = VersionSanitizer.Sanitize(versionOrChannel); - _commandActivity?.SetTag("sdk.requested_version", sanitized); + _commandActivity?.SetTag("dotnet.requested_version", sanitized); } /// - /// Records the source of the SDK request (explicit user input vs default). + /// Records the source of the install request (explicit user input vs default). /// /// The request source: "explicit", "default-latest", or "default-globaljson". /// The sanitized requested value (channel/version). For defaults, this is what was defaulted to. protected void RecordRequestSource(string source, string? requestedValue) { - _commandActivity?.SetTag("sdk.request_source", source); + _commandActivity?.SetTag("dotnet.request_source", source); if (requestedValue != null) { var sanitized = VersionSanitizer.Sanitize(requestedValue); - _commandActivity?.SetTag("sdk.requested", sanitized); + _commandActivity?.SetTag("dotnet.requested", sanitized); } } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index b8ae947fccf6..cb6393a03a60 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -91,8 +91,8 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) Activity.Current?.SetTag("install.path_source", context.PathSource); // Record request source (how the version/channel was determined) - Activity.Current?.SetTag("sdk.request_source", context.RequestSource); - Activity.Current?.SetTag("sdk.requested", VersionSanitizer.Sanitize(context.Channel)); + Activity.Current?.SetTag("dotnet.request_source", context.RequestSource); + Activity.Current?.SetTag("dotnet.requested", VersionSanitizer.Sanitize(context.Channel)); var resolved = CreateInstallRequest(context); diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index 64c135e03ffa..eeca78d8ef32 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -75,16 +75,7 @@ public double Value public void SetTag(string key, object? value) { - if (_activity == null) return; - - // Sanitize URL tags to prevent PII leakage - if (key == "download.url" && value is string url) - { - _activity.SetTag("download.url_domain", UrlSanitizer.SanitizeDomain(url)); - return; - } - - _activity.SetTag(key, value); + _activity?.SetTag(key, value); } public void RecordError(Exception ex) diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index 9ad50f551c73..8dec31e08499 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -89,16 +89,7 @@ public double MaxValue public void SetTag(string key, object? value) { - if (_activity == null) return; - - // Sanitize URL tags to prevent PII leakage - if (key == "download.url" && value is string url) - { - _activity.SetTag("download.url_domain", UrlSanitizer.SanitizeDomain(url)); - return; - } - - _activity.SetTag(key, value); + _activity?.SetTag(key, value); } public void RecordError(Exception ex) diff --git a/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs b/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs index 7b121d9a225a..32aaeb4bdbb1 100644 --- a/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs +++ b/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs @@ -1,59 +1,18 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using LibraryUrlSanitizer = Microsoft.Dotnet.Installation.Internal.UrlSanitizer; + namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// -/// Sanitizes URLs for telemetry to prevent PII leakage. -/// Only known safe domains are reported; unknown domains are replaced with "unknown". +/// Thin wrapper over for backward compatibility. +/// The canonical implementation lives in the installation library so that +/// sanitization happens at the source (closest to where tags are emitted). /// public static class UrlSanitizer { - /// - /// Known .NET download domains that are safe to report in telemetry. - /// Unknown domains are reported as "unknown" to prevent PII leakage - /// from custom/private mirrors. - /// - /// - /// See: https://github.com/dotnet/vscode-dotnet-runtime/blob/main/vscode-dotnet-runtime-library/src/Acquisition/GlobalInstallerResolver.ts - /// - public static readonly IReadOnlyList KnownDownloadDomains = - [ - "download.visualstudio.microsoft.com", - "builds.dotnet.microsoft.com", - "ci.dot.net", - "dotnetcli.blob.core.windows.net", - "dotnetcli.azureedge.net" // Legacy CDN, may still be referenced - ]; - - /// - /// Extracts and sanitizes the domain from a URL for telemetry purposes. - /// Returns "unknown" for unrecognized domains to prevent PII leakage from custom mirrors. - /// - /// The URL to extract the domain from. - /// The domain if known, or "unknown" for unrecognized/private domains. - public static string SanitizeDomain(string? url) - { - if (string.IsNullOrEmpty(url)) - { - return "unknown"; - } + public static IReadOnlyList KnownDownloadDomains => LibraryUrlSanitizer.KnownDownloadDomains; - try - { - var host = new Uri(url).Host; - foreach (var knownDomain in KnownDownloadDomains) - { - if (host.Equals(knownDomain, StringComparison.OrdinalIgnoreCase)) - { - return host; - } - } - return "unknown"; - } - catch - { - return "unknown"; - } - } + public static string SanitizeDomain(string? url) => LibraryUrlSanitizer.SanitizeDomain(url); } diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index c1db16be9faa..e4bd31a73638 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -433,7 +433,7 @@ "version": "KqlItem/1.0", "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"download\" or name == \"extract\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"download.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(version)\n| summarize Count = count() by version\n| order by Count desc\n| take 20\n| render barchart", "size": 1, - "title": "Most Installed SDK Versions", + "title": "Most Installed Versions", "queryType": 0, "resourceType": "microsoft.insights/components", "visualization": "barchart" @@ -445,10 +445,10 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"command/sdk/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend requested = tostring(customDimensions[\"sdk.requested\"]),\n source = tostring(customDimensions[\"sdk.request_source\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| extend display_requested = case(\n source == \"default-latest\", \"(default: latest)\",\n source == \"default-globaljson\", strcat(\"(global.json: \", requested, \")\"),\n source == \"explicit\" and isnotempty(requested), requested,\n isnotempty(requested), requested,\n isnotempty(source), strcat(\"(\", source, \")\"),\n \"(no request data)\")\n| summarize Count = count() by display_requested\n| order by Count desc\n| take 15\n| render barchart", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name in (\"command/sdk/install\", \"command/runtime/install\")\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend requested = tostring(customDimensions[\"dotnet.requested\"]),\n source = tostring(customDimensions[\"dotnet.request_source\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| extend display_requested = case(\n source == \"default-latest\", \"(default: latest)\",\n source == \"default-globaljson\", strcat(\"(global.json: \", requested, \")\"),\n source == \"explicit\" and isnotempty(requested), requested,\n isnotempty(requested), requested,\n isnotempty(source), strcat(\"(\", source, \")\"),\n \"(no request data)\")\n| summarize Count = count() by display_requested\n| order by Count desc\n| take 15\n| render barchart", "size": 1, - "title": "Requested SDK Versions (User Input)", - "noDataMessage": "No SDK install telemetry with request source data yet. This data is populated when users run 'dotnetup sdk install'.", + "title": "Requested Versions (User Input)", + "noDataMessage": "No install telemetry with request source data yet. This data is populated when users run 'dotnetup sdk install' or 'dotnetup runtime install'.", "queryType": 0, "resourceType": "microsoft.insights/components", "visualization": "barchart" @@ -460,10 +460,10 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"command/sdk/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend source = coalesce(tostring(customDimensions[\"sdk.request_source\"]), \"unknown\")\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by source\n| render piechart", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name in (\"command/sdk/install\", \"command/runtime/install\")\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend source = coalesce(tostring(customDimensions[\"dotnet.request_source\"]), \"unknown\")\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count() by source\n| render piechart", "size": 1, "title": "Request Source Distribution", - "noDataMessage": "No SDK install telemetry data yet.", + "noDataMessage": "No install telemetry data yet.", "queryType": 0, "resourceType": "microsoft.insights/components", "visualization": "piechart" From 6f679d0d6d9260d1c9d0fde470ca9961188079be Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 19 Feb 2026 15:15:24 -0800 Subject: [PATCH 43/59] fix ambiguity --- test/dotnetup.Tests/TelemetryTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 0c7a24daf21c..99b58f370d26 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -5,6 +5,7 @@ using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Xunit; +using UrlSanitizer = Microsoft.Dotnet.Installation.Internal.UrlSanitizer; namespace Microsoft.DotNet.Tools.Bootstrapper.Tests; From 0de4847e4cd6f8bc7cc2307755503e113f82588f Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 19 Feb 2026 15:31:27 -0800 Subject: [PATCH 44/59] expect proper mapping to errors in win test --- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 67 ++++----------------- 1 file changed, 12 insertions(+), 55 deletions(-) diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index 4599fa8285aa..af526c56e8bc 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -3,7 +3,6 @@ using System.Net; using System.Net.Sockets; -using System.Runtime.InteropServices; using Microsoft.Dotnet.Installation; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Xunit; @@ -22,15 +21,8 @@ public void GetErrorInfo_IOException_DiskFull_MapsCorrectly() Assert.Equal("DiskFull", info.ErrorType); Assert.Equal(unchecked((int)0x80070070), info.HResult); - // Details contain the win32 error code for PII safety - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Equal("win32_error_112", info.Details); // ERROR_DISK_FULL = 112 - } - else - { - Assert.Equal("ERROR_DISK_FULL", info.Details); - } + // Details contain the symbolic Win32 error name for PII safety + Assert.Equal("ERROR_DISK_FULL", info.Details); } [Fact] @@ -42,15 +34,8 @@ public void GetErrorInfo_IOException_SharingViolation_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("SharingViolation", info.ErrorType); - // Details contain the win32 error code - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Equal("win32_error_32", info.Details); // ERROR_SHARING_VIOLATION = 32 - } - else - { - Assert.Equal("ERROR_SHARING_VIOLATION", info.Details); - } + // Details contain the symbolic Win32 error name + Assert.Equal("ERROR_SHARING_VIOLATION", info.Details); } [Fact] @@ -61,15 +46,8 @@ public void GetErrorInfo_IOException_PathTooLong_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("PathTooLong", info.ErrorType); - // Details contain the win32 error code for PII safety - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Equal("win32_error_206", info.Details); // ERROR_FILENAME_EXCED_RANGE = 206 - } - else - { - Assert.Equal("ERROR_FILENAME_EXCED_RANGE", info.Details); - } + // Details contain the symbolic Win32 error name for PII safety + Assert.Equal("ERROR_FILENAME_EXCED_RANGE", info.Details); } [Fact] @@ -195,15 +173,8 @@ public void GetErrorInfo_HResultAndDetails_ForDiskFullException() Assert.Equal("DiskFull", info.ErrorType); Assert.Equal(unchecked((int)0x80070070), info.HResult); - // Details contain the win32 error code for PII safety - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Equal("win32_error_112", info.Details); // ERROR_DISK_FULL = 112 - } - else - { - Assert.Equal("ERROR_DISK_FULL", info.Details); - } + // Details contain the symbolic Win32 error name for PII safety + Assert.Equal("ERROR_DISK_FULL", info.Details); } private static Exception ThrowTestException() @@ -245,15 +216,8 @@ public void GetErrorInfo_NetworkPathNotFound_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("NetworkPathNotFound", info.ErrorType); - // Details contain the win32 error code for PII safety - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Equal("win32_error_53", info.Details); // ERROR_BAD_NETPATH = 53 - } - else - { - Assert.Equal("ERROR_BAD_NETPATH", info.Details); - } + // Details contain the symbolic Win32 error name for PII safety + Assert.Equal("ERROR_BAD_NETPATH", info.Details); } [Fact] @@ -266,15 +230,8 @@ public void GetErrorInfo_AlreadyExists_MapsCorrectly() Assert.Equal("AlreadyExists", info.ErrorType); Assert.Equal(unchecked((int)0x800700B7), info.HResult); - // Details contain the win32 error code for PII safety - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Equal("win32_error_183", info.Details); // ERROR_ALREADY_EXISTS = 183 - } - else - { - Assert.Equal("ERROR_ALREADY_EXISTS", info.Details); - } + // Details contain the symbolic Win32 error name for PII safety + Assert.Equal("ERROR_ALREADY_EXISTS", info.Details); } // Error category tests From 1d408c90974ddbeb7d34292a5c318984fb266673 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Thu, 19 Feb 2026 16:39:27 -0800 Subject: [PATCH 45/59] nologo in test to prevent first run output --- test/dotnetup.Tests/DnupE2Etest.cs | 3 +++ test/dotnetup.Tests/MuxerHandlerTests.cs | 2 ++ test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs | 3 +++ 3 files changed, 8 insertions(+) diff --git a/test/dotnetup.Tests/DnupE2Etest.cs b/test/dotnetup.Tests/DnupE2Etest.cs index ea674e0916c3..a2c3e567761c 100644 --- a/test/dotnetup.Tests/DnupE2Etest.cs +++ b/test/dotnetup.Tests/DnupE2Etest.cs @@ -261,6 +261,9 @@ private static void VerifyEnvScriptWorks(string shell, string installPath, strin process.StartInfo.RedirectStandardError = true; process.StartInfo.WorkingDirectory = tempRoot; + // Suppress .NET welcome message / first-run experience in test output + process.StartInfo.Environment["DOTNET_NOLOGO"] = "1"; + // output which is a framework-dependent AppHost. Ensure DOTNET_ROOT is set so // the AppHost can locate the runtime. On CI each script step gets a fresh shell, // so DOTNET_ROOT from the restore step isn't inherited. The env script sourced diff --git a/test/dotnetup.Tests/MuxerHandlerTests.cs b/test/dotnetup.Tests/MuxerHandlerTests.cs index b2205ac30cdd..ff3b19e834ec 100644 --- a/test/dotnetup.Tests/MuxerHandlerTests.cs +++ b/test/dotnetup.Tests/MuxerHandlerTests.cs @@ -404,6 +404,8 @@ public void GetDotnetProcessPidInfo_DoesNotKillProcess() proc.StartInfo.CreateNoWindow = true; proc.StartInfo.RedirectStandardOutput = true; proc.StartInfo.RedirectStandardError = true; + // Suppress .NET welcome message / first-run experience in test output + proc.StartInfo.Environment["DOTNET_NOLOGO"] = "1"; proc.Start(); try diff --git a/test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs b/test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs index ded7074c3c68..9bc3dbc38ebf 100644 --- a/test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs +++ b/test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs @@ -153,6 +153,9 @@ public static (int exitCode, string output) RunDotnetupProcess(string[] args, bo process.StartInfo.RedirectStandardError = captureOutput; process.StartInfo.WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory; + // Suppress the .NET welcome message / first-run experience in test output + process.StartInfo.Environment["DOTNET_NOLOGO"] = "1"; + StringBuilder outputBuilder = new(); if (captureOutput) { From f9078bf064fc278cf9898eb713dea7e372a139d1 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 20 Feb 2026 16:14:09 -0800 Subject: [PATCH 46/59] pr feedback round 1 - simpler changes --- .../DotnetInstallException.cs | 2 +- .../Internal/DotnetArchiveDownloader.cs | 2 +- src/Installer/dotnetup/CommandBase.cs | 24 +++----- .../Commands/Shared/InstallExecutor.cs | 4 +- .../Commands/Shared/InstallPathResolver.cs | 14 ++--- .../Commands/Shared/InstallWorkflow.cs | 6 +- .../dotnetup/Commands/Shared/PathSource.cs | 25 +++++++++ src/Installer/dotnetup/DotnetupPaths.cs | 47 +++++----------- src/Installer/dotnetup/Telemetry/BuildInfo.cs | 41 +++----------- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 20 ------- .../Telemetry/ErrorCategoryClassifier.cs | 2 +- .../Telemetry/ExceptionErrorMapper.cs | 9 +-- .../Telemetry/TelemetryCommonProperties.cs | 23 +------- .../dotnetup/Telemetry/TelemetryEventData.cs | 56 ------------------- .../dotnetup/Telemetry/UrlSanitizer.cs | 18 ------ .../dotnetup/Telemetry/VersionSanitizer.cs | 6 +- .../dotnetup/Telemetry/dotnetup-workbook.json | 2 +- ...metry-notice.txt => dotnetup-telemetry.md} | 0 test/dotnetup.Tests/ErrorCodeMapperTests.cs | 2 +- 19 files changed, 83 insertions(+), 220 deletions(-) create mode 100644 src/Installer/dotnetup/Commands/Shared/PathSource.cs delete mode 100644 src/Installer/dotnetup/Telemetry/TelemetryEventData.cs delete mode 100644 src/Installer/dotnetup/Telemetry/UrlSanitizer.cs rename src/Installer/dotnetup/docs/{telemetry-notice.txt => dotnetup-telemetry.md} (100%) diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs index 43c92262685c..f2944d5b466c 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs @@ -18,7 +18,7 @@ public enum DotnetInstallErrorCode ReleaseNotFound, /// No matching file was found for the platform/architecture. - NoMatchingFile, + NoMatchingReleaseFileForPlatform, /// Failed to download the archive. DownloadFailed, diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index 9eadc3ec842d..cead11ac8117 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -205,7 +205,7 @@ public void DownloadArchiveWithVerification( if (targetFile == null) { throw new DotnetInstallException( - DotnetInstallErrorCode.NoMatchingFile, + DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform, $"No matching file found for {installRequest.Component} version {resolvedVersion} on {installRequest.InstallRoot.Architecture}", version: resolvedVersion.ToString(), component: installRequest.Component.ToString()); diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index b0c4ff300362..9f52211f3180 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -4,6 +4,7 @@ using System.CommandLine; using System.Diagnostics; using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; @@ -17,6 +18,7 @@ public abstract class CommandBase { protected ParseResult _parseResult; private Activity? _commandActivity; + private int _exitCode; protected CommandBase(ParseResult parseResult) { @@ -25,46 +27,36 @@ protected CommandBase(ParseResult parseResult) /// /// Executes the command with automatic telemetry tracking. + /// Activities automatically track duration via start/stop — no Stopwatch needed. /// /// The exit code of the command. public int Execute() { var commandName = GetCommandName(); _commandActivity = DotnetupTelemetry.Instance.StartCommand(commandName); - var stopwatch = Stopwatch.StartNew(); + _exitCode = 1; try { - var exitCode = ExecuteCore(); - - stopwatch.Stop(); - _commandActivity?.SetTag("exit.code", exitCode); - _commandActivity?.SetTag("duration_ms", stopwatch.Elapsed.TotalMilliseconds); - _commandActivity?.SetStatus(exitCode == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); - - return exitCode; + _exitCode = ExecuteCore(); + return _exitCode; } catch (DotnetInstallException ex) { // Known installation errors - print a clean user-friendly message - stopwatch.Stop(); - _commandActivity?.SetTag("duration_ms", stopwatch.Elapsed.TotalMilliseconds); - _commandActivity?.SetTag("exit.code", 1); DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]"); return 1; } catch (Exception ex) { - stopwatch.Stop(); - _commandActivity?.SetTag("duration_ms", stopwatch.Elapsed.TotalMilliseconds); - _commandActivity?.SetTag("exit.code", 1); DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); - // Status is already set inside RecordException with error type (no PII) throw; } finally { + _commandActivity?.SetTag("exit.code", _exitCode); + _commandActivity?.SetStatus(_exitCode == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); _commandActivity?.Dispose(); } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index 7aa9ddfbfc0f..f7dae553e075 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -204,7 +204,7 @@ public static bool IsAdminInstallPath(string path) /// /// The install path to classify. /// How the path was determined (e.g., "global_json", "explicit"). Null to skip source-based classification. - public static string ClassifyInstallPath(string path, string? pathSource = null) + public static string ClassifyInstallPath(string path, PathSource? pathSource = null) { var fullPath = Path.GetFullPath(path); @@ -259,7 +259,7 @@ public static string ClassifyInstallPath(string path, string? pathSource = null) // If the path was specified by global.json and doesn't match a well-known location, // classify it as global_json rather than generic "other" - if (pathSource == "global_json") + if (pathSource == PathSource.GlobalJson) { return "global_json"; } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs index 55063f008346..dfbf882d8a87 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs @@ -26,11 +26,11 @@ public InstallPathResolver(IDotnetInstallManager dotnetInstaller) /// /// The final resolved install path. /// The install path from global.json, if any. - /// How the path was determined: "global_json", "explicit", "existing_user_install", "interactive_prompt", or "default". + /// How the path was determined. public record InstallPathResolutionResult( string ResolvedInstallPath, string? InstallPathFromGlobalJson, - string PathSource); + PathSource PathSource); /// /// Resolves the install path using the following precedence: @@ -69,17 +69,17 @@ public record InstallPathResolutionResult( if (explicitInstallPath is not null) { - return new InstallPathResolutionResult(explicitInstallPath, installPathFromGlobalJson, "explicit"); + return new InstallPathResolutionResult(explicitInstallPath, installPathFromGlobalJson, PathSource.Explicit); } if (installPathFromGlobalJson is not null) { - return new InstallPathResolutionResult(installPathFromGlobalJson, installPathFromGlobalJson, "global_json"); + return new InstallPathResolutionResult(installPathFromGlobalJson, installPathFromGlobalJson, PathSource.GlobalJson); } if (currentDotnetInstallRoot is not null && currentDotnetInstallRoot.InstallType == InstallType.User) { - return new InstallPathResolutionResult(currentDotnetInstallRoot.Path, installPathFromGlobalJson, "existing_user_install"); + return new InstallPathResolutionResult(currentDotnetInstallRoot.Path, installPathFromGlobalJson, PathSource.ExistingUserInstall); } if (interactive) @@ -87,9 +87,9 @@ public record InstallPathResolutionResult( var prompted = SpectreAnsiConsole.Prompt( new TextPrompt($"Where should we install the {componentDescription} to?") .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); - return new InstallPathResolutionResult(prompted, installPathFromGlobalJson, "interactive_prompt"); + return new InstallPathResolutionResult(prompted, installPathFromGlobalJson, PathSource.InteractivePrompt); } - return new InstallPathResolutionResult(_dotnetInstaller.GetDefaultDotnetInstallPath(), installPathFromGlobalJson, "default"); + return new InstallPathResolutionResult(_dotnetInstaller.GetDefaultDotnetInstallPath(), installPathFromGlobalJson, PathSource.Default); } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index cb6393a03a60..5cd7c3c1d39d 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -52,7 +52,7 @@ private record WorkflowContext( bool SetDefaultInstall, bool? UpdateGlobalJson, string RequestSource, - string PathSource); + PathSource PathSource); public InstallWorkflowResult Execute(InstallWorkflowOptions options) { @@ -78,7 +78,7 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) "Use your system package manager or the official installer for system-wide installations."); Activity.Current?.SetTag("error.type", "admin_path_blocked"); Activity.Current?.SetTag("install.path_type", "admin"); - Activity.Current?.SetTag("install.path_source", context.PathSource); + Activity.Current?.SetTag("install.path_source", context.PathSource.ToString().ToLowerInvariant()); Activity.Current?.SetTag("error.category", "user"); return new InstallWorkflowResult(1, null); } @@ -88,7 +88,7 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) Activity.Current?.SetTag("install.existing_install_type", context.CurrentInstallRoot?.InstallType.ToString() ?? "none"); Activity.Current?.SetTag("install.set_default", context.SetDefaultInstall); Activity.Current?.SetTag("install.path_type", InstallExecutor.ClassifyInstallPath(context.InstallPath, context.PathSource)); - Activity.Current?.SetTag("install.path_source", context.PathSource); + Activity.Current?.SetTag("install.path_source", context.PathSource.ToString().ToLowerInvariant()); // Record request source (how the version/channel was determined) Activity.Current?.SetTag("dotnet.request_source", context.RequestSource); diff --git a/src/Installer/dotnetup/Commands/Shared/PathSource.cs b/src/Installer/dotnetup/Commands/Shared/PathSource.cs new file mode 100644 index 000000000000..cc3c0fa873f2 --- /dev/null +++ b/src/Installer/dotnetup/Commands/Shared/PathSource.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; + +/// +/// Describes how an install path was determined during path resolution. +/// +internal enum PathSource +{ + /// The user explicitly specified the install path (e.g., --install-path option). + Explicit, + + /// The install path came from a global.json sdk-path. + GlobalJson, + + /// An existing user-level .NET installation was found and reused. + ExistingUserInstall, + + /// The user was prompted interactively and chose a path. + InteractivePrompt, + + /// No other source applied; the default install path was used. + Default, +} diff --git a/src/Installer/dotnetup/DotnetupPaths.cs b/src/Installer/dotnetup/DotnetupPaths.cs index 5cd1fdff9ed2..1e4556369bba 100644 --- a/src/Installer/dotnetup/DotnetupPaths.cs +++ b/src/Installer/dotnetup/DotnetupPaths.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.InteropServices; - namespace Microsoft.DotNet.Tools.Bootstrapper; /// @@ -20,13 +18,14 @@ internal static class DotnetupPaths /// /// Gets the base data directory for dotnetup. /// On Windows: %LOCALAPPDATA%\dotnetup - /// On Unix: ~/dotnetup (folder in user profile) + /// On macOS: ~/Library/Application Support/dotnetup + /// On Linux: ~/.local/share/dotnetup /// /// - /// Returns null if the base directory cannot be determined. /// Can be overridden via DOTNET_TESTHOOK_DOTNETUP_DATA_DIR environment variable. + /// Throws if the base directory cannot be determined. /// - public static string? DataDirectory + public static string DataDirectory { get { @@ -45,7 +44,7 @@ public static string? DataDirectory var baseDir = GetBaseDirectory(); if (string.IsNullOrEmpty(baseDir)) { - return null; + throw new InvalidOperationException("Could not determine the local application data directory. Ensure the environment is properly configured."); } _dataDirectory = Path.Combine(baseDir, DotnetupFolderName); @@ -57,10 +56,9 @@ public static string? DataDirectory /// Gets the path to the dotnetup manifest file. /// /// - /// Returns null if the data directory cannot be determined. /// Can be overridden via DOTNET_TESTHOOK_MANIFEST_PATH environment variable. /// - public static string? ManifestPath + public static string ManifestPath { get { @@ -71,25 +69,14 @@ public static string? ManifestPath return overridePath; } - var dataDir = DataDirectory; - return dataDir is null ? null : Path.Combine(dataDir, ManifestFileName); + return Path.Combine(DataDirectory, ManifestFileName); } } /// /// Gets the path to the telemetry first-run sentinel file. /// - /// - /// Returns null if the data directory cannot be determined. - /// - public static string? TelemetrySentinelPath - { - get - { - var dataDir = DataDirectory; - return dataDir is null ? null : Path.Combine(dataDir, TelemetrySentinelFileName); - } - } + public static string TelemetrySentinelPath => Path.Combine(DataDirectory, TelemetrySentinelFileName); /// /// Ensures the data directory exists, creating it if necessary. @@ -97,14 +84,9 @@ public static string? TelemetrySentinelPath /// True if the directory exists or was created; false otherwise. public static bool EnsureDataDirectoryExists() { - var dataDir = DataDirectory; - if (string.IsNullOrEmpty(dataDir)) - { - return false; - } - try { + var dataDir = DataDirectory; if (!Directory.Exists(dataDir)) { Directory.CreateDirectory(dataDir); @@ -122,11 +104,10 @@ public static bool EnsureDataDirectoryExists() /// private static string? GetBaseDirectory() { - // On Windows: use LocalApplicationData (%LOCALAPPDATA%) - // On Unix: use UserProfile (~) - the folder name "dotnetup" will be used (not hidden) - // Unix convention is to use ~/.config for app data, but we use ~/dotnetup for simplicity - return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) - : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + // Use LocalApplicationData on all platforms: + // Windows: %LOCALAPPDATA% (e.g., C:\Users\\AppData\Local) + // macOS: ~/Library/Application Support + // Linux: $XDG_DATA_HOME or ~/.local/share + return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); } } diff --git a/src/Installer/dotnetup/Telemetry/BuildInfo.cs b/src/Installer/dotnetup/Telemetry/BuildInfo.cs index 519d35c2c4b4..10b4efa2fb80 100644 --- a/src/Installer/dotnetup/Telemetry/BuildInfo.cs +++ b/src/Installer/dotnetup/Telemetry/BuildInfo.cs @@ -10,48 +10,23 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// public static class BuildInfo { - private static string? _version; - private static string? _commitSha; - private static bool _initialized; + private static readonly Lazy<(string Version, string CommitSha)> s_buildInfo = new(() => + { + var assembly = Assembly.GetExecutingAssembly(); + var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion ?? "unknown"; + return ParseInformationalVersion(informationalVersion); + }); /// /// Gets the version string (e.g., "1.0.0"). /// - public static string Version - { - get - { - EnsureInitialized(); - return _version!; - } - } + public static string Version => s_buildInfo.Value.Version; /// /// Gets the short commit SHA (7 characters, e.g., "abc123d"). /// Returns "unknown" if not available. /// - public static string CommitSha - { - get - { - EnsureInitialized(); - return _commitSha!; - } - } - - private static void EnsureInitialized() - { - if (_initialized) - { - return; - } - - var assembly = Assembly.GetExecutingAssembly(); - var informationalVersion = assembly.GetCustomAttribute()?.InformationalVersion ?? "unknown"; - - (_version, _commitSha) = ParseInformationalVersion(informationalVersion); - _initialized = true; - } + public static string CommitSha => s_buildInfo.Value.CommitSha; /// /// Parses the informational version string to extract version and commit SHA. diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index ebd5391df0d2..51756823c0f9 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -197,26 +197,6 @@ public void PostEvent( } } - /// - /// Posts an install completed event. - /// - public void PostInstallEvent(InstallEventData data) - { - PostEvent("install/completed", new Dictionary - { - ["component"] = data.Component, - ["version"] = data.Version, - ["previous_version"] = data.PreviousVersion ?? string.Empty, - ["was_update"] = data.WasUpdate.ToString(), - ["install_root_hash"] = TelemetryCommonProperties.HashPath(data.InstallRoot) - }, new Dictionary - { - ["download_ms"] = data.DownloadDuration.TotalMilliseconds, - ["extract_ms"] = data.ExtractionDuration.TotalMilliseconds, - ["archive_bytes"] = data.ArchiveSizeBytes - }); - } - /// /// Flushes any pending telemetry. /// diff --git a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs index 73185b32b0e3..8d44dfbab7db 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs @@ -65,7 +65,7 @@ internal static ErrorCategory ClassifyInstallError(DotnetInstallErrorCode errorC DotnetInstallErrorCode.NetworkError => ErrorCategory.User, // User's network issue // Product errors - issues we can take action on - DotnetInstallErrorCode.NoMatchingFile => ErrorCategory.Product, // Our manifest/logic issue + DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform => ErrorCategory.Product, // Our manifest/logic issue DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue diff --git a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs index 34d05fe12e1b..0872bb1fc38d 100644 --- a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; @@ -83,10 +84,10 @@ internal static ExceptionErrorInfo Map(Exception ex) SourceLocation: sourceLocation, ExceptionChain: exceptionChain), - // Invalid argument — user provided bad input + // Invalid argument — likely a bug in our code (arguments are set programmatically) ArgumentException argEx => new ExceptionErrorInfo( "InvalidArgument", - Category: ErrorCategory.User, + Category: ErrorCategory.Product, Details: argEx.ParamName, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), @@ -98,10 +99,10 @@ internal static ExceptionErrorInfo Map(Exception ex) SourceLocation: sourceLocation, ExceptionChain: exceptionChain), - // Not supported — could be user trying unsupported scenario + // Not supported — likely a product issue (missing implementation or unsupported code path) NotSupportedException => new ExceptionErrorInfo( "NotSupported", - Category: ErrorCategory.User, + Category: ErrorCategory.Product, SourceLocation: sourceLocation, ExceptionChain: exceptionChain), diff --git a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs index 01f56a265da4..7e83f86075c9 100644 --- a/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs +++ b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs @@ -33,7 +33,7 @@ public static IEnumerable> GetCommonAttributes(stri { ["session.id"] = sessionId, ["device.id"] = s_deviceId.Value, - ["os.platform"] = GetOSPlatform(), + ["os.platform"] = RuntimeInformation.OSDescription, ["os.version"] = Environment.OSVersion.VersionString, ["process.arch"] = RuntimeInformation.ProcessArchitecture.ToString(), ["ci.detected"] = s_isCIEnvironment.Value, @@ -74,23 +74,6 @@ public static string Hash(string input) return Convert.ToHexString(hashBytes).ToLowerInvariant(); } - private static string GetOSPlatform() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return "Windows"; - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return "Linux"; - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return "macOS"; - } - return "Unknown"; - } - private static string GetDeviceId() { try @@ -100,8 +83,8 @@ private static string GetDeviceId() } catch { - // Fallback to a new GUID if device ID retrieval fails - return Guid.NewGuid().ToString(); + // Fallback to empty string if device ID retrieval fails (consistent with SDK behavior) + return string.Empty; } } diff --git a/src/Installer/dotnetup/Telemetry/TelemetryEventData.cs b/src/Installer/dotnetup/Telemetry/TelemetryEventData.cs deleted file mode 100644 index 24df5750f3b5..000000000000 --- a/src/Installer/dotnetup/Telemetry/TelemetryEventData.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; - -/// -/// Strongly-typed event data for install operations. -/// -/// The component being installed (e.g., "sdk", "runtime"). -/// The version being installed. -/// The previous version if this is an update. -/// Whether this was an update operation. -/// The installation root path (will be hashed). -/// Time spent downloading. -/// Time spent extracting. -/// Size of the downloaded archive in bytes. -public record InstallEventData( - string Component, - string Version, - string? PreviousVersion, - bool WasUpdate, - string InstallRoot, - TimeSpan DownloadDuration, - TimeSpan ExtractionDuration, - long ArchiveSizeBytes -); - -/// -/// Strongly-typed event data for update operations. -/// -/// The component being updated. -/// The version updating from. -/// The version updating to. -/// The update channel (e.g., "lts", "sts"). -/// Whether this was an automatic update. -public record UpdateEventData( - string Component, - string FromVersion, - string ToVersion, - string UpdateChannel, - bool WasAutomatic -); - -/// -/// Strongly-typed event data for command completion. -/// -/// The command that was executed. -/// The exit code of the command. -/// The duration of the command. -/// Whether the command succeeded. -public record CommandCompletedEventData( - string Command, - int ExitCode, - TimeSpan Duration, - bool Success -); diff --git a/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs b/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs deleted file mode 100644 index 32aaeb4bdbb1..000000000000 --- a/src/Installer/dotnetup/Telemetry/UrlSanitizer.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using LibraryUrlSanitizer = Microsoft.Dotnet.Installation.Internal.UrlSanitizer; - -namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; - -/// -/// Thin wrapper over for backward compatibility. -/// The canonical implementation lives in the installation library so that -/// sanitization happens at the source (closest to where tags are emitted). -/// -public static class UrlSanitizer -{ - public static IReadOnlyList KnownDownloadDomains => LibraryUrlSanitizer.KnownDownloadDomains; - - public static string SanitizeDomain(string? url) => LibraryUrlSanitizer.SanitizeDomain(url); -} diff --git a/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs b/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs index 71612b07f081..4a1ddeb33bd9 100644 --- a/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs +++ b/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs @@ -1,6 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -// VersionSanitizer has been moved to Microsoft.Dotnet.Installation.Internal. -// This using directive re-exports it for backward compatibility within the dotnetup project. -global using Microsoft.Dotnet.Installation.Internal; +// VersionSanitizer lives in Microsoft.Dotnet.Installation.Internal. +// Files that need it should add: using Microsoft.Dotnet.Installation.Internal; + diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index e4bd31a73638..24736ed5f3a8 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -609,7 +609,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| where success == true\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n duration = todouble(customDimensions[\"duration_ms\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| summarize \n Count = count(),\n P50 = percentile(duration, 50),\n P90 = percentile(duration, 90),\n P99 = percentile(duration, 99),\n Avg = avg(duration)\n by command\n| project Command = command, \n Count,\n ['P50 (ms)'] = round(P50, 0), \n ['P90 (ms)'] = round(P90, 0), \n ['P99 (ms)'] = round(P99, 0),\n ['Avg (ms)'] = round(Avg, 0)", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| where success == true\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| summarize \n Count = count(),\n P50 = percentile(duration, 50),\n P90 = percentile(duration, 90),\n P99 = percentile(duration, 99),\n Avg = avg(duration)\n by command\n| project Command = command, \n Count,\n ['P50 (ms)'] = round(P50, 0), \n ['P90 (ms)'] = round(P90, 0), \n ['P99 (ms)'] = round(P99, 0),\n ['Avg (ms)'] = round(Avg, 0)", "size": 1, "title": "Command Duration Percentiles (Successful Only)", "queryType": 0, diff --git a/src/Installer/dotnetup/docs/telemetry-notice.txt b/src/Installer/dotnetup/docs/dotnetup-telemetry.md similarity index 100% rename from src/Installer/dotnetup/docs/telemetry-notice.txt rename to src/Installer/dotnetup/docs/dotnetup-telemetry.md diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index af526c56e8bc..d58697a0cd15 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -242,7 +242,7 @@ public void GetErrorInfo_AlreadyExists_MapsCorrectly() [InlineData(DotnetInstallErrorCode.PermissionDenied, ErrorCategory.User)] [InlineData(DotnetInstallErrorCode.DiskFull, ErrorCategory.User)] [InlineData(DotnetInstallErrorCode.NetworkError, ErrorCategory.User)] - [InlineData(DotnetInstallErrorCode.NoMatchingFile, ErrorCategory.Product)] + [InlineData(DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.DownloadFailed, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.HashMismatch, ErrorCategory.Product)] [InlineData(DotnetInstallErrorCode.ExtractionFailed, ErrorCategory.Product)] From 964f1675abbf7eb38b140a3e80a9066e72acbfa1 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 20 Feb 2026 16:19:41 -0800 Subject: [PATCH 47/59] hard code tags, consolidate mapping --- src/Installer/dotnetup/CommandBase.cs | 25 ++---- .../Commands/Shared/InstallExecutor.cs | 10 ++- .../Commands/Shared/InstallWalkthrough.cs | 5 +- .../Commands/Shared/InstallWorkflow.cs | 40 ++++----- .../InstallerOrchestratorSingleton.cs | 8 +- .../dotnetup/NonUpdatingProgressTarget.cs | 2 +- src/Installer/dotnetup/Program.cs | 4 +- .../dotnetup/SpectreProgressTarget.cs | 2 +- .../dotnetup/Telemetry/DotnetupTelemetry.cs | 8 +- .../Telemetry/ErrorCategoryClassifier.cs | 69 ++++++++++------ .../dotnetup/Telemetry/ErrorCodeMapper.cs | 19 ++--- .../Telemetry/ExceptionErrorMapper.cs | 3 +- .../dotnetup/Telemetry/HResultMapper.cs | 82 ------------------- .../dotnetup/Telemetry/TelemetryTagNames.cs | 49 +++++++++++ 14 files changed, 156 insertions(+), 170 deletions(-) delete mode 100644 src/Installer/dotnetup/Telemetry/HResultMapper.cs create mode 100644 src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index 9f52211f3180..774e7e9bbbaa 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -55,7 +55,7 @@ public int Execute() } finally { - _commandActivity?.SetTag("exit.code", _exitCode); + _commandActivity?.SetTag(TelemetryTagNames.ExitCode, _exitCode); _commandActivity?.SetStatus(_exitCode == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); _commandActivity?.Dispose(); } @@ -68,16 +68,9 @@ public int Execute() protected abstract int ExecuteCore(); /// - /// Gets the command name for telemetry purposes. - /// Override to provide a custom name. + /// Gets the command name for telemetry purposes (e.g., "sdk/install", "list"). /// - /// The command name (e.g., "sdk/install"). - protected virtual string GetCommandName() - { - // Default: derive from class name (SdkInstallCommand -> "sdkinstall") - var name = GetType().Name; - return name.Replace("Command", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant(); - } + protected abstract string GetCommandName(); /// /// Adds a tag to the current command activity. @@ -98,11 +91,11 @@ protected void SetCommandTag(string key, object? value) /// Error category: "user" for input/environment issues, "product" for bugs (default). protected void RecordFailure(string reason, string? message = null, string category = "product") { - _commandActivity?.SetTag("error.type", reason); - _commandActivity?.SetTag("error.category", category); + _commandActivity?.SetTag(TelemetryTagNames.ErrorType, reason); + _commandActivity?.SetTag(TelemetryTagNames.ErrorCategory, category); if (message != null) { - _commandActivity?.SetTag("error.message", message); + _commandActivity?.SetTag(TelemetryTagNames.ErrorMessage, message); } } @@ -114,7 +107,7 @@ protected void RecordFailure(string reason, string? message = null, string categ protected void RecordRequestedVersion(string? versionOrChannel) { var sanitized = VersionSanitizer.Sanitize(versionOrChannel); - _commandActivity?.SetTag("dotnet.requested_version", sanitized); + _commandActivity?.SetTag(TelemetryTagNames.DotnetRequestedVersion, sanitized); } /// @@ -124,11 +117,11 @@ protected void RecordRequestedVersion(string? versionOrChannel) /// The sanitized requested value (channel/version). For defaults, this is what was defaulted to. protected void RecordRequestSource(string source, string? requestedValue) { - _commandActivity?.SetTag("dotnet.request_source", source); + _commandActivity?.SetTag(TelemetryTagNames.DotnetRequestSource, source); if (requestedValue != null) { var sanitized = VersionSanitizer.Sanitize(requestedValue); - _commandActivity?.SetTag("dotnet.requested", sanitized); + _commandActivity?.SetTag(TelemetryTagNames.DotnetRequested, sanitized); } } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index f7dae553e075..bc6f570fac61 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -80,7 +80,15 @@ public static InstallResult ExecuteInstall( return new InstallResult(false, null); } - SpectreAnsiConsole.MarkupLine($"[green]Installed {componentDescription} {installResult.Install.Version}, available via {installResult.Install.InstallRoot}[/]"); + if (installResult.WasAlreadyInstalled) + { + SpectreAnsiConsole.MarkupLine($"[green]{componentDescription} {installResult.Install.Version} is already installed at {installResult.Install.InstallRoot}[/]"); + } + else + { + SpectreAnsiConsole.MarkupLine($"[green]Installed {componentDescription} {installResult.Install.Version}, available via {installResult.Install.InstallRoot}[/]"); + } + return new InstallResult(true, installResult.Install, installResult.WasAlreadyInstalled); } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs index 249ab7fa64ea..c93aa120a335 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; @@ -53,7 +54,7 @@ public List GetAdditionalAdminVersionsToMigrate( if (setDefaultInstall && currentInstallRoot?.InstallType == InstallType.Admin) { // Track admin-to-user migration scenario - Activity.Current?.SetTag("install.migrating_from_admin", true); + Activity.Current?.SetTag(TelemetryTagNames.InstallMigratingFromAdmin, true); if (_options.Interactive) { @@ -67,7 +68,7 @@ public List GetAdditionalAdminVersionsToMigrate( defaultValue: true)) { additionalVersions.Add(latestAdminVersion); - Activity.Current?.SetTag("install.admin_version_copied", true); + Activity.Current?.SetTag(TelemetryTagNames.InstallAdminVersionCopied, true); } } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index 5cd7c3c1d39d..e5b27b579c3a 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -57,16 +57,16 @@ private record WorkflowContext( public InstallWorkflowResult Execute(InstallWorkflowOptions options) { // Record telemetry for the install request - Activity.Current?.SetTag("install.component", options.Component.ToString()); - Activity.Current?.SetTag("install.requested_version", VersionSanitizer.Sanitize(options.VersionOrChannel)); - Activity.Current?.SetTag("install.path_explicit", options.InstallPath is not null); + Activity.Current?.SetTag(TelemetryTagNames.InstallComponent, options.Component.ToString()); + Activity.Current?.SetTag(TelemetryTagNames.InstallRequestedVersion, VersionSanitizer.Sanitize(options.VersionOrChannel)); + Activity.Current?.SetTag(TelemetryTagNames.InstallPathExplicit, options.InstallPath is not null); var context = ResolveWorkflowContext(options, out string? error); if (context is null) { Console.Error.WriteLine(error); - Activity.Current?.SetTag("error.type", "context_resolution_failed"); - Activity.Current?.SetTag("error.category", "user"); + Activity.Current?.SetTag(TelemetryTagNames.ErrorType, "context_resolution_failed"); + Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "user"); return new InstallWorkflowResult(1, null); } @@ -76,40 +76,40 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) Console.Error.WriteLine($"Error: The install path '{context.InstallPath}' is a system-managed .NET location. " + "dotnetup installs to user-level locations only. " + "Use your system package manager or the official installer for system-wide installations."); - Activity.Current?.SetTag("error.type", "admin_path_blocked"); - Activity.Current?.SetTag("install.path_type", "admin"); - Activity.Current?.SetTag("install.path_source", context.PathSource.ToString().ToLowerInvariant()); - Activity.Current?.SetTag("error.category", "user"); + Activity.Current?.SetTag(TelemetryTagNames.ErrorType, "admin_path_blocked"); + Activity.Current?.SetTag(TelemetryTagNames.InstallPathType, "admin"); + Activity.Current?.SetTag(TelemetryTagNames.InstallPathSource, context.PathSource.ToString().ToLowerInvariant()); + Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "user"); return new InstallWorkflowResult(1, null); } // Record resolved context telemetry - Activity.Current?.SetTag("install.has_global_json", context.GlobalJson?.GlobalJsonPath is not null); - Activity.Current?.SetTag("install.existing_install_type", context.CurrentInstallRoot?.InstallType.ToString() ?? "none"); - Activity.Current?.SetTag("install.set_default", context.SetDefaultInstall); - Activity.Current?.SetTag("install.path_type", InstallExecutor.ClassifyInstallPath(context.InstallPath, context.PathSource)); - Activity.Current?.SetTag("install.path_source", context.PathSource.ToString().ToLowerInvariant()); + Activity.Current?.SetTag(TelemetryTagNames.InstallHasGlobalJson, context.GlobalJson?.GlobalJsonPath is not null); + Activity.Current?.SetTag(TelemetryTagNames.InstallExistingInstallType, context.CurrentInstallRoot?.InstallType.ToString() ?? "none"); + Activity.Current?.SetTag(TelemetryTagNames.InstallSetDefault, context.SetDefaultInstall); + Activity.Current?.SetTag(TelemetryTagNames.InstallPathType, InstallExecutor.ClassifyInstallPath(context.InstallPath, context.PathSource)); + Activity.Current?.SetTag(TelemetryTagNames.InstallPathSource, context.PathSource.ToString().ToLowerInvariant()); // Record request source (how the version/channel was determined) - Activity.Current?.SetTag("dotnet.request_source", context.RequestSource); - Activity.Current?.SetTag("dotnet.requested", VersionSanitizer.Sanitize(context.Channel)); + Activity.Current?.SetTag(TelemetryTagNames.DotnetRequestSource, context.RequestSource); + Activity.Current?.SetTag(TelemetryTagNames.DotnetRequested, VersionSanitizer.Sanitize(context.Channel)); var resolved = CreateInstallRequest(context); // Record resolved version - Activity.Current?.SetTag("install.resolved_version", resolved.ResolvedVersion?.ToString()); + Activity.Current?.SetTag(TelemetryTagNames.InstallResolvedVersion, resolved.ResolvedVersion?.ToString()); var installResult = ExecuteInstallations(context, resolved); if (installResult is null) { - Activity.Current?.SetTag("error.type", "install_failed"); - Activity.Current?.SetTag("error.category", "product"); + Activity.Current?.SetTag(TelemetryTagNames.ErrorType, "install_failed"); + Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "product"); return new InstallWorkflowResult(1, resolved); } ApplyPostInstallConfiguration(context, resolved); - Activity.Current?.SetTag("install.result", installResult.WasAlreadyInstalled ? "already_installed" : "installed"); + Activity.Current?.SetTag(TelemetryTagNames.InstallResult, installResult.WasAlreadyInstalled ? "already_installed" : "installed"); InstallExecutor.DisplayComplete(); return new InstallWorkflowResult(0, resolved); } diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index 722320c42ee7..27907ecaddc4 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -74,8 +74,8 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres { // Log for telemetry but don't block - we may risk clobber but prefer UX over safety here // See: https://github.com/dotnet/sdk/issues/52789 for tracking - Activity.Current?.SetTag("install.mutex_lock_failed", true); - Activity.Current?.SetTag("install.mutex_lock_phase", "pre_check"); + Activity.Current?.SetTag(TelemetryTagNames.InstallMutexLockFailed, true); + Activity.Current?.SetTag(TelemetryTagNames.InstallMutexLockPhase, "pre_check"); Console.Error.WriteLine("Warning: Could not acquire installation lock. Another dotnetup process may be running. Proceeding anyway."); } if (InstallAlreadyExists(install, customManifestPath)) @@ -115,8 +115,8 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres { // Log for telemetry but don't block - we may risk clobber but prefer UX over safety here // See: https://github.com/dotnet/sdk/issues/52789 for tracking - Activity.Current?.SetTag("install.mutex_lock_failed", true); - Activity.Current?.SetTag("install.mutex_lock_phase", "commit"); + Activity.Current?.SetTag(TelemetryTagNames.InstallMutexLockFailed, true); + Activity.Current?.SetTag(TelemetryTagNames.InstallMutexLockPhase, "commit"); Console.Error.WriteLine("Warning: Could not acquire installation lock. Another dotnetup process may be running. Proceeding anyway."); } if (InstallAlreadyExists(install, customManifestPath)) diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index eeca78d8ef32..e97d9debc713 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -28,7 +28,7 @@ public IProgressTask AddTask(string activityName, string description, double max { var activity = InstallationActivitySource.ActivitySource.StartActivity(activityName, ActivityKind.Internal); // Tag library activities so consumers know they came from dotnetup CLI - activity?.SetTag("caller", "dotnetup"); + activity?.SetTag(TelemetryTagNames.Caller, "dotnetup"); var task = new ProgressTaskImpl(description, activity) { MaxValue = maxValue }; _tasks.Add(task); AnsiConsole.WriteLine(description + "..."); diff --git a/src/Installer/dotnetup/Program.cs b/src/Installer/dotnetup/Program.cs index cc3596f5408b..0bf9184be95d 100644 --- a/src/Installer/dotnetup/Program.cs +++ b/src/Installer/dotnetup/Program.cs @@ -25,7 +25,7 @@ public static int Main(string[] args) try { var result = Parser.Invoke(args); - rootActivity?.SetTag("exit.code", result); + rootActivity?.SetTag(TelemetryTagNames.ExitCode, result); rootActivity?.SetStatus(result == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); return result; } @@ -33,7 +33,7 @@ public static int Main(string[] args) { // Catch-all for unhandled exceptions DotnetupTelemetry.Instance.RecordException(rootActivity, ex); - rootActivity?.SetTag("exit.code", 1); + rootActivity?.SetTag(TelemetryTagNames.ExitCode, 1); // Log the error and return non-zero exit code Console.Error.WriteLine($"Error: {ex.Message}"); diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index 8dec31e08499..ec51d656284e 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -41,7 +41,7 @@ public IProgressTask AddTask(string activityName, string description, double max { var activity = InstallationActivitySource.ActivitySource.StartActivity(activityName, ActivityKind.Internal); // Tag library activities so consumers know they came from dotnetup CLI - activity?.SetTag("caller", "dotnetup"); + activity?.SetTag(TelemetryTagNames.Caller, "dotnetup"); var task = new ProgressTaskImpl(_progressContext.AddTask(description, maxValue: maxValue), activity); _tasks.Add(task); return task; diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index 51756823c0f9..315f2489ed93 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -122,15 +122,15 @@ private DotnetupTelemetry() var activity = CommandSource.StartActivity($"command/{commandName}", ActivityKind.Internal); if (activity != null) { - activity.SetTag("command.name", commandName); + activity.SetTag(TelemetryTagNames.CommandName, commandName); // Add common properties to each span for App Insights customDimensions foreach (var attr in TelemetryCommonProperties.GetCommonAttributes(_sessionId)) { activity.SetTag(attr.Key, attr.Value?.ToString()); } } - activity?.SetTag("caller", "dotnetup"); - activity?.SetTag("session.id", _sessionId); + activity?.SetTag(TelemetryTagNames.Caller, "dotnetup"); + activity?.SetTag(TelemetryTagNames.SessionId, _sessionId); return activity; } @@ -178,7 +178,7 @@ public void PostEvent( { activity.SetTag(attr.Key, attr.Value?.ToString()); } - activity.SetTag("caller", "dotnetup"); + activity.SetTag(TelemetryTagNames.Caller, "dotnetup"); if (properties != null) { diff --git a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs index 8d44dfbab7db..c32f61624fa5 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs @@ -18,34 +18,53 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; internal static class ErrorCategoryClassifier { /// - /// Classifies an IO error type (from ) as product or user error. + /// Classifies an IO error by its HResult, returning the error type, category, and optional details + /// in a single lookup. This avoids a two-step HResult→errorType→category pipeline that could + /// get out of sync. /// - internal static ErrorCategory ClassifyIOError(string errorType) + internal static (string ErrorType, ErrorCategory Category, string? Details) ClassifyIOErrorByHResult(int hResult) { - return errorType switch + return hResult switch { - // User environment issues - we can't control these - "DiskFull" => ErrorCategory.User, - "PermissionDenied" => ErrorCategory.User, - "InvalidPath" => ErrorCategory.User, // User specified invalid path - "PathNotFound" => ErrorCategory.User, // User's directory doesn't exist - "NetworkPathNotFound" => ErrorCategory.User, // Network issue - "NetworkNameDeleted" => ErrorCategory.User, // Network issue - "DeviceFailure" => ErrorCategory.User, // Hardware issue - - // Product issues - we should handle these gracefully - "SharingViolation" => ErrorCategory.Product, // Could be our mutex/lock issue - "LockViolation" => ErrorCategory.Product, // Could be our mutex/lock issue - "PathTooLong" => ErrorCategory.Product, // We control the install path - "SemaphoreTimeout" => ErrorCategory.Product, // Could be our concurrency issue - "AlreadyExists" => ErrorCategory.Product, // We should handle existing files gracefully - "FileExists" => ErrorCategory.Product, // We should handle existing files gracefully - "FileNotFound" => ErrorCategory.Product, // Our code referenced missing file - "GeneralFailure" => ErrorCategory.Product, // Unknown IO error - "InvalidParameter" => ErrorCategory.Product, // Our code passed bad params - "IOException" => ErrorCategory.Product, // Generic IO - assume product - - _ => ErrorCategory.Product // Unknown - assume product + // Disk/storage errors — user environment + unchecked((int)0x80070070) => ("DiskFull", ErrorCategory.User, "ERROR_DISK_FULL"), + unchecked((int)0x80070027) => ("DiskFull", ErrorCategory.User, "ERROR_HANDLE_DISK_FULL"), + + // Permission errors — user environment + unchecked((int)0x80070005) => ("PermissionDenied", ErrorCategory.User, "ERROR_ACCESS_DENIED"), + + // Path errors — user environment + unchecked((int)0x8007007B) => ("InvalidPath", ErrorCategory.User, "ERROR_INVALID_NAME"), + unchecked((int)0x80070003) => ("PathNotFound", ErrorCategory.User, "ERROR_PATH_NOT_FOUND"), + + // Network errors — user environment + unchecked((int)0x80070035) => ("NetworkPathNotFound", ErrorCategory.User, "ERROR_BAD_NETPATH"), + unchecked((int)0x80070033) => ("NetworkNameDeleted", ErrorCategory.User, "ERROR_NETNAME_DELETED"), + + // Device/hardware errors — user environment + unchecked((int)0x8007001F) => ("DeviceFailure", ErrorCategory.User, "ERROR_GEN_FAILURE"), + + // Sharing/lock violations — product issues (our mutex/lock logic) + unchecked((int)0x80070020) => ("SharingViolation", ErrorCategory.Product, "ERROR_SHARING_VIOLATION"), + unchecked((int)0x80070021) => ("LockViolation", ErrorCategory.Product, "ERROR_LOCK_VIOLATION"), + + // Semaphore timeout — product issue (our concurrency logic) + unchecked((int)0x80070079) => ("SemaphoreTimeout", ErrorCategory.Product, "ERROR_SEM_TIMEOUT"), + + // Path too long — product issue (we control install paths) + unchecked((int)0x800700CE) => ("PathTooLong", ErrorCategory.Product, "ERROR_FILENAME_EXCED_RANGE"), + + // File existence — product issue (we should handle gracefully) + unchecked((int)0x80070002) => ("FileNotFound", ErrorCategory.Product, "ERROR_FILE_NOT_FOUND"), + unchecked((int)0x800700B7) => ("AlreadyExists", ErrorCategory.Product, "ERROR_ALREADY_EXISTS"), + unchecked((int)0x80070050) => ("FileExists", ErrorCategory.Product, "ERROR_FILE_EXISTS"), + + // General failures — product issue + unchecked((int)0x80004005) => ("GeneralFailure", ErrorCategory.Product, "E_FAIL"), + unchecked((int)0x80070057) => ("InvalidParameter", ErrorCategory.Product, "ERROR_INVALID_PARAMETER"), + + // Unknown — include raw HResult, assume product + _ => ("IOException", ErrorCategory.Product, hResult != 0 ? $"0x{hResult:X8}" : null) }; } diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index adf0cda8fdf5..34c7f7a9dac6 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -51,8 +51,7 @@ public sealed record ExceptionErrorInfo( /// Implementation is split across single-responsibility helpers: /// /// — exception-type dispatch and enrichment -/// — Product vs User classification -/// — Win32 HResult → telemetry label +/// — Product vs User classification + HResult mapping /// — PII-safe network exception diagnostics /// — stack-trace source location and exception chains /// @@ -71,24 +70,24 @@ public static void ApplyErrorTags(Activity? activity, ExceptionErrorInfo errorIn if (activity is null) return; activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); - activity.SetTag("error.type", errorInfo.ErrorType); + activity.SetTag(TelemetryTagNames.ErrorType, errorInfo.ErrorType); if (errorCode is not null) { - activity.SetTag("error.code", errorCode); + activity.SetTag(TelemetryTagNames.ErrorCode, errorCode); } - activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); + activity.SetTag(TelemetryTagNames.ErrorCategory, errorInfo.Category.ToString().ToLowerInvariant()); // Use pattern matching to set optional tags only if they have values if (errorInfo is { StatusCode: { } statusCode }) - activity.SetTag("error.http_status", statusCode); + activity.SetTag(TelemetryTagNames.ErrorHttpStatus, statusCode); if (errorInfo is { HResult: { } hResult }) - activity.SetTag("error.hresult", hResult); + activity.SetTag(TelemetryTagNames.ErrorHResult, hResult); if (errorInfo is { Details: { } details }) - activity.SetTag("error.details", details); + activity.SetTag(TelemetryTagNames.ErrorDetails, details); if (errorInfo is { SourceLocation: { } sourceLocation }) - activity.SetTag("error.source_location", sourceLocation); + activity.SetTag(TelemetryTagNames.ErrorSourceLocation, sourceLocation); if (errorInfo is { ExceptionChain: { } exceptionChain }) - activity.SetTag("error.exception_chain", exceptionChain); + activity.SetTag(TelemetryTagNames.ErrorExceptionChain, exceptionChain); // NOTE: We intentionally do NOT call activity.RecordException(ex) // because exception messages/stacks can contain PII diff --git a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs index 0872bb1fc38d..ea1195815321 100644 --- a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs @@ -165,8 +165,7 @@ private static ExceptionErrorInfo MapInstallException( /// private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain) { - var (errorType, details) = HResultMapper.GetErrorTypeFromHResult(ioEx.HResult); - var category = ErrorCategoryClassifier.ClassifyIOError(errorType); + var (errorType, category, details) = ErrorCategoryClassifier.ClassifyIOErrorByHResult(ioEx.HResult); return new ExceptionErrorInfo( errorType, diff --git a/src/Installer/dotnetup/Telemetry/HResultMapper.cs b/src/Installer/dotnetup/Telemetry/HResultMapper.cs deleted file mode 100644 index ef620226eda7..000000000000 --- a/src/Installer/dotnetup/Telemetry/HResultMapper.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; - -/// -/// Maps Win32 HResult codes to short, telemetry-safe error type labels. -/// -/// -/// This custom mapping is necessary because .NET does not expose a public API for converting -/// HResult codes to symbolic names suitable for telemetry. The alternatives were considered: -/// -/// - new Win32Exception(errorCode).Message returns localized, human-readable strings -/// (e.g., "Access is denied") that vary by locale, may contain file paths (PII risk), -/// and are not machine-parseable. -/// -/// - Marshal.GetExceptionForHR(hr) returns COMException for most HRs, which -/// loses the specific error code information. -/// -/// - Marshal.GetPInvokeErrorMessage(errorCode) produces localized messages, same issues. -/// -/// - The runtime's Exception::GetHRSymbolicName() (in ex.cpp) does map HRs to symbolic -/// names but is internal C++ code, not exposed to managed code. -/// -/// The runtime does throw specific IOException subclasses for some Win32 errors -/// (e.g., FileNotFoundException for ERROR_FILE_NOT_FOUND, DirectoryNotFoundException -/// for ERROR_PATH_NOT_FOUND). These are handled upstream in -/// before reaching the HResult mapper. However, many Win32 errors (sharing violation, disk full, -/// lock violation, etc.) produce a plain IOException with only the HResult set to -/// 0x80070000 | win32ErrorCode, not COR_E_IO (0x80131620). For these, the HResult -/// is the only way to determine the specific failure. -/// -internal static class HResultMapper -{ - /// - /// Gets a short, telemetry-safe error type label and optional Win32 symbolic name from an HResult. - /// - /// The HResult from an . - /// - /// A tuple of (errorType, details) where errorType is a short label like "DiskFull" - /// and details is the Win32 symbolic name like "ERROR_DISK_FULL" (or a hex string for unknown codes). - /// - internal static (string errorType, string? details) GetErrorTypeFromHResult(int hResult) - { - return hResult switch - { - // Disk/storage errors - unchecked((int)0x80070070) => ("DiskFull", "ERROR_DISK_FULL"), - unchecked((int)0x80070027) => ("DiskFull", "ERROR_HANDLE_DISK_FULL"), - - // Semaphore/concurrency errors - unchecked((int)0x80070079) => ("SemaphoreTimeout", "ERROR_SEM_TIMEOUT"), - - // Permission errors - unchecked((int)0x80070005) => ("PermissionDenied", "ERROR_ACCESS_DENIED"), - unchecked((int)0x80070020) => ("SharingViolation", "ERROR_SHARING_VIOLATION"), - unchecked((int)0x80070021) => ("LockViolation", "ERROR_LOCK_VIOLATION"), - - // Path errors - unchecked((int)0x800700CE) => ("PathTooLong", "ERROR_FILENAME_EXCED_RANGE"), - unchecked((int)0x8007007B) => ("InvalidPath", "ERROR_INVALID_NAME"), - unchecked((int)0x80070003) => ("PathNotFound", "ERROR_PATH_NOT_FOUND"), - unchecked((int)0x80070002) => ("FileNotFound", "ERROR_FILE_NOT_FOUND"), - - // File/directory existence errors - unchecked((int)0x800700B7) => ("AlreadyExists", "ERROR_ALREADY_EXISTS"), - unchecked((int)0x80070050) => ("FileExists", "ERROR_FILE_EXISTS"), - - // Network errors - unchecked((int)0x80070035) => ("NetworkPathNotFound", "ERROR_BAD_NETPATH"), - unchecked((int)0x80070033) => ("NetworkNameDeleted", "ERROR_NETNAME_DELETED"), - unchecked((int)0x80004005) => ("GeneralFailure", "E_FAIL"), - - // Device/hardware errors - unchecked((int)0x8007001F) => ("DeviceFailure", "ERROR_GEN_FAILURE"), - unchecked((int)0x80070057) => ("InvalidParameter", "ERROR_INVALID_PARAMETER"), - - // Default: include raw HResult for debugging - _ => ("IOException", hResult != 0 ? $"0x{hResult:X8}" : null) - }; - } -} diff --git a/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs b/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs new file mode 100644 index 000000000000..f897be94a686 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +/// +/// Constants for telemetry tag names used across dotnetup. +/// Centralizes tag names to prevent typos and enable rename-safe refactoring. +/// +internal static class TelemetryTagNames +{ + // Command-level tags + public const string CommandName = "command.name"; + public const string ExitCode = "exit.code"; + public const string SessionId = "session.id"; + public const string Caller = "caller"; + + // Error tags + public const string ErrorType = "error.type"; + public const string ErrorCategory = "error.category"; + public const string ErrorCode = "error.code"; + public const string ErrorMessage = "error.message"; + public const string ErrorDetails = "error.details"; + public const string ErrorHttpStatus = "error.http_status"; + public const string ErrorHResult = "error.hresult"; + public const string ErrorSourceLocation = "error.source_location"; + public const string ErrorExceptionChain = "error.exception_chain"; + + // Install tags + public const string InstallComponent = "install.component"; + public const string InstallRequestedVersion = "install.requested_version"; + public const string InstallPathExplicit = "install.path_explicit"; + public const string InstallPathType = "install.path_type"; + public const string InstallPathSource = "install.path_source"; + public const string InstallHasGlobalJson = "install.has_global_json"; + public const string InstallExistingInstallType = "install.existing_install_type"; + public const string InstallSetDefault = "install.set_default"; + public const string InstallResolvedVersion = "install.resolved_version"; + public const string InstallResult = "install.result"; + public const string InstallMutexLockFailed = "install.mutex_lock_failed"; + public const string InstallMutexLockPhase = "install.mutex_lock_phase"; + public const string InstallMigratingFromAdmin = "install.migrating_from_admin"; + public const string InstallAdminVersionCopied = "install.admin_version_copied"; + + // Dotnet request tags + public const string DotnetRequestSource = "dotnet.request_source"; + public const string DotnetRequested = "dotnet.requested"; + public const string DotnetRequestedVersion = "dotnet.requested_version"; +} From 90188849b5d4bffa4a0a110e234c7c853c905c8d Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Fri, 20 Feb 2026 16:27:36 -0800 Subject: [PATCH 48/59] simplify stack trace collection --- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 15 ++- .../Telemetry/ExceptionErrorMapper.cs | 34 ++--- .../dotnetup/Telemetry/ExceptionInspector.cs | 126 +++++++----------- .../dotnetup/Telemetry/TelemetryTagNames.cs | 2 +- .../dotnetup/Telemetry/dotnetup-workbook.json | 6 +- .../dotnetup/docs/dotnetup-telemetry.md | 21 +-- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 21 ++- test/dotnetup.Tests/InstallTelemetryTests.cs | 14 +- 8 files changed, 102 insertions(+), 137 deletions(-) diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 34c7f7a9dac6..2c8ba2cd8d3c 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -32,7 +32,7 @@ public enum ErrorCategory /// HTTP status code if applicable. /// Win32 HResult if applicable. /// Additional context (no PII - sanitized values only). -/// Method name from our code where error occurred (no file paths). +/// Full stack trace without exception messages (safe for telemetry). /// Chain of exception types for wrapped exceptions. public sealed record ExceptionErrorInfo( string ErrorType, @@ -40,7 +40,7 @@ public sealed record ExceptionErrorInfo( int? StatusCode = null, int? HResult = null, string? Details = null, - string? SourceLocation = null, + string? StackTrace = null, string? ExceptionChain = null); /// @@ -53,7 +53,7 @@ public sealed record ExceptionErrorInfo( /// — exception-type dispatch and enrichment /// — Product vs User classification + HResult mapping /// — PII-safe network exception diagnostics -/// — stack-trace source location and exception chains +/// — PII-safe stack traces and exception chains /// /// public static class ErrorCodeMapper @@ -84,13 +84,14 @@ public static void ApplyErrorTags(Activity? activity, ExceptionErrorInfo errorIn activity.SetTag(TelemetryTagNames.ErrorHResult, hResult); if (errorInfo is { Details: { } details }) activity.SetTag(TelemetryTagNames.ErrorDetails, details); - if (errorInfo is { SourceLocation: { } sourceLocation }) - activity.SetTag(TelemetryTagNames.ErrorSourceLocation, sourceLocation); + if (errorInfo is { StackTrace: { } stackTrace }) + activity.SetTag(TelemetryTagNames.ErrorStackTrace, stackTrace); if (errorInfo is { ExceptionChain: { } exceptionChain }) activity.SetTag(TelemetryTagNames.ErrorExceptionChain, exceptionChain); - // NOTE: We intentionally do NOT call activity.RecordException(ex) - // because exception messages/stacks can contain PII + // NOTE: We intentionally do NOT call activity.RecordException(ex) because + // exception messages can contain PII. Instead we collect stack traces with + // messages stripped via ExceptionInspector.GetStackTraceWithoutMessage. } /// diff --git a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs index ea1195815321..fea58363462e 100644 --- a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs @@ -32,7 +32,7 @@ internal static ExceptionErrorInfo Map(Exception ex) } // Get common enrichment data - var sourceLocation = ExceptionInspector.GetSafeSourceLocation(ex); + var stackTrace = ExceptionInspector.GetStackTraceWithoutMessage(ex); var exceptionChain = ExceptionInspector.GetExceptionChain(ex); return ex switch @@ -40,14 +40,14 @@ internal static ExceptionErrorInfo Map(Exception ex) // DotnetInstallException has specific error codes — categorize by error code. // Sanitize the version to prevent PII leakage (user could have typed anything). // For network-related errors, also check the inner exception for more details. - DotnetInstallException installEx => MapInstallException(installEx, sourceLocation, exceptionChain), + DotnetInstallException installEx => MapInstallException(installEx, stackTrace, exceptionChain), // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues HttpRequestException httpEx => new ExceptionErrorInfo( httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", Category: ErrorCategoryClassifier.ClassifyHttpError(httpEx.StatusCode), StatusCode: (int?)httpEx.StatusCode, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), // FileNotFoundException before IOException (it derives from IOException). @@ -58,30 +58,30 @@ internal static ExceptionErrorInfo Map(Exception ex) Category: ErrorCategory.Product, HResult: fnfEx.HResult, Details: fnfEx.FileName is not null ? "file_specified" : null, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), // Permission denied — user environment issue (needs elevation or different permissions) UnauthorizedAccessException => new ExceptionErrorInfo( "PermissionDenied", Category: ErrorCategory.User, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), // Directory not found — could be user specified bad path DirectoryNotFoundException => new ExceptionErrorInfo( "DirectoryNotFound", Category: ErrorCategory.User, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), - IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain), + IOException ioEx => MapIOException(ioEx, stackTrace, exceptionChain), // User cancelled the operation OperationCanceledException => new ExceptionErrorInfo( "Cancelled", Category: ErrorCategory.User, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), // Invalid argument — likely a bug in our code (arguments are set programmatically) @@ -89,35 +89,35 @@ internal static ExceptionErrorInfo Map(Exception ex) "InvalidArgument", Category: ErrorCategory.Product, Details: argEx.ParamName, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), // Invalid operation — usually a bug in our code InvalidOperationException => new ExceptionErrorInfo( "InvalidOperation", Category: ErrorCategory.Product, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), // Not supported — likely a product issue (missing implementation or unsupported code path) NotSupportedException => new ExceptionErrorInfo( "NotSupported", Category: ErrorCategory.Product, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), // Timeout — network/environment issue outside our control TimeoutException => new ExceptionErrorInfo( "Timeout", Category: ErrorCategory.User, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain), // Unknown exceptions default to product (fail-safe — we should handle known cases) _ => new ExceptionErrorInfo( ex.GetType().Name, Category: ErrorCategory.Product, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain) }; } @@ -128,7 +128,7 @@ internal static ExceptionErrorInfo Map(Exception ex) /// private static ExceptionErrorInfo MapInstallException( DotnetInstallException installEx, - string? sourceLocation, + string? stackTrace, string? exceptionChain) { var errorCode = installEx.ErrorCode; @@ -156,14 +156,14 @@ private static ExceptionErrorInfo MapInstallException( Category: baseCategory, StatusCode: httpStatus, Details: details, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain); } /// /// Maps a generic using its HResult. /// - private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain) + private static ExceptionErrorInfo MapIOException(IOException ioEx, string? stackTrace, string? exceptionChain) { var (errorType, category, details) = ErrorCategoryClassifier.ClassifyIOErrorByHResult(ioEx.HResult); @@ -172,7 +172,7 @@ private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourc Category: category, HResult: ioEx.HResult, Details: details, - SourceLocation: sourceLocation, + StackTrace: stackTrace, ExceptionChain: exceptionChain); } } diff --git a/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs b/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs index c71787bf846d..0b631aba1a5d 100644 --- a/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs +++ b/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs @@ -1,82 +1,31 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; - namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// /// Extracts PII-safe diagnostic metadata from exceptions for telemetry: -/// source location (stack-trace inspection) and exception-type chains. +/// full stack traces (without exception messages) and exception-type chains. /// /// -/// All output is designed to be safe for telemetry ingestion: -/// -/// Source locations contain only type/method names and line numbers from our -/// assemblies — never file paths, user names, or arguments. -/// Exception chains contain only CLR type names, which are stable and non-PII. -/// -/// Uses (.NET 8+) for AOT-compatible stack frame inspection. +/// Follows the same pattern as the .NET SDK's TelemetryFilter.ExceptionToStringWithoutMessage. +/// Exception messages are stripped because they can contain user-provided input (file paths, +/// version strings, etc.), but stack traces are safe — especially in NativeAOT where they +/// contain only method names without source file paths or line numbers. /// internal static class ExceptionInspector { /// - /// Gets a safe source location from the stack trace — finds the first frame from our assemblies. - /// This is typically the code in dotnetup that called into BCL/external code that threw. - /// No file paths that could contain user info. Line numbers from our code are included as they are not PII. + /// Gets the full exception details without messages, following the SDK pattern. + /// Includes exception type names, full stack traces, and inner exception chains. + /// Messages are stripped to avoid PII; stack traces are kept as they only contain + /// type/method names (especially safe in NativeAOT). /// - internal static string? GetSafeSourceLocation(Exception ex) + internal static string? GetStackTraceWithoutMessage(Exception ex) { try { - var stackTrace = new StackTrace(ex, fNeedFileInfo: true); - var frames = stackTrace.GetFrames(); - - if (frames == null || frames.Length == 0) - { - return null; - } - - string? throwSite = null; - - // Walk the stack from throw site upward, looking for the first frame in our code. - // This finds the dotnetup code that called into BCL/external code that threw. - foreach (var frame in frames) - { - var methodInfo = DiagnosticMethodInfo.Create(frame); - if (methodInfo == null) continue; - - // DiagnosticMethodInfo provides DeclaringTypeName which includes the full type name - var declaringType = methodInfo.DeclaringTypeName; - if (string.IsNullOrEmpty(declaringType)) continue; - - // Capture the first frame as the throw site (fallback) - if (throwSite == null) - { - var throwTypeName = ExtractTypeName(declaringType); - throwSite = $"[BCL]{throwTypeName}.{methodInfo.Name}"; - } - - // Check if it's from our assemblies by looking at the namespace prefix - if (IsOwnedNamespace(declaringType)) - { - // Extract just the type name (last part after the last dot, before any generic params) - var typeName = ExtractTypeName(declaringType); - - // Include line number for our code (not PII), but never file paths - var lineNumber = frame.GetFileLineNumber(); - var location = $"{typeName}.{methodInfo.Name}"; - if (lineNumber > 0) - { - location += $":{lineNumber}"; - } - return location; - } - } - - // If we didn't find our code, return the throw site as a fallback. - // The throw site is from BCL or our NuGet dependencies (e.g., System.IO, System.Net) - return throwSite; + return ExceptionToStringWithoutMessage(ex); } catch { @@ -121,31 +70,54 @@ internal static class ExceptionInspector } /// - /// Checks if a type name belongs to one of our owned namespaces. + /// Converts an exception to string without its message, following the .NET SDK pattern. + /// For AggregateExceptions, recursively processes all inner exceptions. /// - private static bool IsOwnedNamespace(string declaringType) + private static string ExceptionToStringWithoutMessage(Exception e) { - return declaringType.StartsWith("Microsoft.DotNet.Tools.Bootstrapper", StringComparison.Ordinal) || - declaringType.StartsWith("Microsoft.Dotnet.Installation", StringComparison.Ordinal); + if (e is AggregateException aggregate) + { + var text = NonAggregateExceptionToStringWithoutMessage(aggregate); + + for (int i = 0; i < aggregate.InnerExceptions.Count; i++) + { + text = string.Format("{0}{1}---> (Inner Exception #{2}) {3}{4}{5}", + text, + Environment.NewLine, + i, + ExceptionToStringWithoutMessage(aggregate.InnerExceptions[i]), + "<---", + Environment.NewLine); + } + + return text; + } + + return NonAggregateExceptionToStringWithoutMessage(e); } /// - /// Extracts just the type name from a fully qualified type name. + /// Converts a non-aggregate exception to string: type name + inner exceptions + stack trace. + /// Messages are intentionally omitted to avoid PII. /// - private static string ExtractTypeName(string fullTypeName) + private static string NonAggregateExceptionToStringWithoutMessage(Exception e) { - var typeName = fullTypeName; - var lastDot = typeName.LastIndexOf('.'); - if (lastDot >= 0) + const string EndOfInnerExceptionStackTrace = "--- End of inner exception stack trace ---"; + + var s = e.GetType().ToString(); + + if (e.InnerException != null) { - typeName = typeName.Substring(lastDot + 1); + s = s + " ---> " + ExceptionToStringWithoutMessage(e.InnerException) + Environment.NewLine + + " " + EndOfInnerExceptionStackTrace; } - // Remove generic arity if present (e.g., "List`1" -> "List") - var genericMarker = typeName.IndexOf('`'); - if (genericMarker >= 0) + + var stackTrace = e.StackTrace; + if (stackTrace != null) { - typeName = typeName.Substring(0, genericMarker); + s += Environment.NewLine + stackTrace; } - return typeName; + + return s; } } diff --git a/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs b/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs index f897be94a686..1e342300cd70 100644 --- a/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs +++ b/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs @@ -23,7 +23,7 @@ internal static class TelemetryTagNames public const string ErrorDetails = "error.details"; public const string ErrorHttpStatus = "error.http_status"; public const string ErrorHResult = "error.hresult"; - public const string ErrorSourceLocation = "error.source_location"; + public const string ErrorStackTrace = "error.stack_trace"; public const string ErrorExceptionChain = "error.exception_chain"; // Install tags diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index 24736ed5f3a8..873b100c4c16 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -524,7 +524,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n source_location = tostring(customDimensions[\"error.source_location\"]),\n exception_chain = tostring(customDimensions[\"error.exception_chain\"]),\n hresult = tostring(customDimensions[\"error.hresult\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, error_details, source_location, exception_chain, hresult, os, version\n| order by timestamp desc\n| take 25", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n exception_chain = tostring(customDimensions[\"error.exception_chain\"]),\n hresult = tostring(customDimensions[\"error.hresult\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, error_details, stack_trace, exception_chain, hresult, os, version\n| order by timestamp desc\n| take 25", "size": 1, "title": "Recent Product Failures (Detailed)", "queryType": 0, @@ -537,9 +537,9 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend source_location = tostring(customDimensions[\"error.source_location\"]),\n error_type = tostring(customDimensions[\"error.type\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(source_location)\n| summarize Count = count() by source_location, error_type\n| order by Count desc\n| take 20\n| project ['Source Location'] = source_location, ['Error Type'] = error_type, Count", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n error_type = tostring(customDimensions[\"error.type\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(stack_trace)\n| summarize Count = count() by stack_trace, error_type\n| order by Count desc\n| take 20\n| project ['Stack Trace'] = stack_trace, ['Error Type'] = error_type, Count", "size": 1, - "title": "Product Errors by Source Location", + "title": "Product Errors by Stack Trace", "queryType": 0, "resourceType": "microsoft.insights/components", "visualization": "table" diff --git a/src/Installer/dotnetup/docs/dotnetup-telemetry.md b/src/Installer/dotnetup/docs/dotnetup-telemetry.md index 4202718c7e34..8bce4c3f190f 100644 --- a/src/Installer/dotnetup/docs/dotnetup-telemetry.md +++ b/src/Installer/dotnetup/docs/dotnetup-telemetry.md @@ -38,23 +38,16 @@ Data collected includes: - Whether running in a CI environment - Whether running from an LLM agent (e.g., GitHub Copilot, Claude) - Exit code / success or failure status -- For failures: error type, error category, and sanitized error details - (no file paths) +- For failures: error type, error category, sanitized error details + (no file paths), and the full stack trace with exception messages removed ## Crash Exception Telemetry -If dotnetup crashes, it collects the exception type, error category, and a -source location identifying the dotnetup or dotnetup dependency method where the error -originated. The source location includes the type name, method name, and line -number from dotnetup source code. If the exception wraps -inner exceptions, the exception type chain is also recorded. - -Example of collected crash data: - - ErrorType: IOException - Category: Product - SourceLocation: Downloader.DownloadAsync:145 - ExceptionChain: IOException->SocketException +If dotnetup crashes, it collects the name of the exception and the stack trace +of dotnetup code, following the same approach as the .NET SDK. Exception messages +are stripped from the stack trace because they may contain user-provided input. +For more details on crash exception telemetry, see the +[.NET CLI telemetry documentation](https://aka.ms/dotnet-cli-telemetry). ## CI and LLM Agent Detection diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index d58697a0cd15..4e6322689e46 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -119,34 +119,33 @@ public void GetErrorInfo_InvalidOperationException_MapsCorrectly() } [Fact] - public void GetErrorInfo_ExceptionFromOurCode_IncludesSourceLocation() + public void GetErrorInfo_ExceptionFromOurCode_IncludesStackTrace() { // Throw from a method to get a real stack trace var ex = ThrowTestException(); var info = ErrorCodeMapper.GetErrorInfo(ex); - // Source location is only populated for our owned assemblies (dotnetup, Microsoft.Dotnet.Installation) - // In tests, we won't have those on the stack, so source location will be null + // Stack trace is collected with messages stripped // The important thing is that the method doesn't throw Assert.Equal("InvalidOperation", info.ErrorType); } [Fact] - public void GetErrorInfo_SourceLocation_FiltersToOwnedNamespaces() + public void GetErrorInfo_StackTrace_ContainsFramesWithoutMessages() { - // Verify that source location filtering works by namespace prefix + // Verify that stack trace is collected and messages are stripped // We must throw and catch to get a stack trace - exceptions created with 'new' have no trace var ex = ThrowTestException(); var info = ErrorCodeMapper.GetErrorInfo(ex); - // Source location should be populated since test assembly is in an owned namespace - // (Microsoft.DotNet.Tools.Bootstrapper.Tests starts with Microsoft.DotNet.Tools.Bootstrapper) - Assert.NotNull(info.SourceLocation); - // The format is "TypeName.MethodName" - no [BCL] prefix since we found owned code - Assert.DoesNotContain("[BCL]", info.SourceLocation); - Assert.Contains("ThrowTestException", info.SourceLocation); + // Stack trace should be populated since we threw a real exception + Assert.NotNull(info.StackTrace); + // Should contain the method name from the stack + Assert.Contains("ThrowTestException", info.StackTrace); + // Should NOT contain the exception message (messages are stripped for PII safety) + Assert.DoesNotContain("Test exception", info.StackTrace); } [Fact] diff --git a/test/dotnetup.Tests/InstallTelemetryTests.cs b/test/dotnetup.Tests/InstallTelemetryTests.cs index 7a2471d7fb61..21ca80c2d7a0 100644 --- a/test/dotnetup.Tests/InstallTelemetryTests.cs +++ b/test/dotnetup.Tests/InstallTelemetryTests.cs @@ -266,7 +266,7 @@ public void ApplyErrorTags_SetsAllRequiredTags() HResult: unchecked((int)0x80070070), StatusCode: null, Details: "win32_error_112", - SourceLocation: "InstallExecutor.cs:42", + StackTrace: "InstallExecutor.cs:42", ExceptionChain: "IOException", Category: ErrorCategory.User); @@ -278,7 +278,7 @@ public void ApplyErrorTags_SetsAllRequiredTags() Assert.Equal("user", a.GetTagItem("error.category")); Assert.Equal(unchecked((int)0x80070070), a.GetTagItem("error.hresult")); Assert.Equal("win32_error_112", a.GetTagItem("error.details")); - Assert.Equal("InstallExecutor.cs:42", a.GetTagItem("error.source_location")); + Assert.Equal("InstallExecutor.cs:42", a.GetTagItem("error.stack_trace")); Assert.Equal("IOException", a.GetTagItem("error.exception_chain")); } @@ -297,7 +297,7 @@ public void ApplyErrorTags_WithErrorCode_SetsErrorCodeTag() HResult: null, StatusCode: 404, Details: null, - SourceLocation: null, + StackTrace: null, ExceptionChain: "HttpRequestException", Category: ErrorCategory.Product); @@ -321,7 +321,7 @@ public void ApplyErrorTags_WithNullActivity_DoesNotThrow() HResult: null, StatusCode: null, Details: null, - SourceLocation: null, + StackTrace: null, ExceptionChain: null, Category: ErrorCategory.Product); @@ -345,7 +345,7 @@ public void ApplyErrorTags_NullOptionalFields_DoesNotSetOptionalTags() HResult: null, StatusCode: null, Details: null, - SourceLocation: null, + StackTrace: null, ExceptionChain: null, Category: ErrorCategory.Product); @@ -359,7 +359,7 @@ public void ApplyErrorTags_NullOptionalFields_DoesNotSetOptionalTags() Assert.Null(a.GetTagItem("error.hresult")); Assert.Null(a.GetTagItem("error.http_status")); Assert.Null(a.GetTagItem("error.details")); - Assert.Null(a.GetTagItem("error.source_location")); + Assert.Null(a.GetTagItem("error.stack_trace")); Assert.Null(a.GetTagItem("error.exception_chain")); Assert.Null(a.GetTagItem("error.code")); } @@ -379,7 +379,7 @@ public void ApplyErrorTags_SetsActivityStatusToError() HResult: null, StatusCode: null, Details: null, - SourceLocation: null, + StackTrace: null, ExceptionChain: null, Category: ErrorCategory.Product); From a857a847a2abb079e4dc396994ccaa37a9f85342 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 11:26:26 -0800 Subject: [PATCH 49/59] PR Feedback - Reduce exception parsing, fix workbook, error telemetry --- eng/restore-toolset.ps1 | 243 ++++--- .../Internal/DotnetArchiveDownloader.cs | 1 + .../Internal/ScopedMutex.cs | 18 +- .../Internal/VersionSanitizer.cs | 100 ++- src/Installer/dotnetup/CommandBase.cs | 5 - .../DefaultInstall/DefaultInstallCommand.cs | 4 + .../PrintEnvScript/PrintEnvScriptCommand.cs | 2 + .../Runtime/Install/RuntimeInstallCommand.cs | 3 + .../Commands/Shared/InstallExecutor.cs | 10 +- .../Commands/Shared/InstallWorkflow.cs | 4 +- src/Installer/dotnetup/Program.cs | 7 + .../dotnetup/Telemetry/ErrorCodeMapper.cs | 616 +++++++++++++++++- .../Telemetry/ExceptionErrorMapper.cs | 178 ----- .../dotnetup/Telemetry/ExceptionInspector.cs | 123 ---- .../dotnetup/Telemetry/VersionSanitizer.cs | 6 - .../dotnetup/Telemetry/dotnetup-workbook.json | 4 +- .../DotnetArchiveDownloaderTests.cs | 3 +- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 33 +- .../InstallPathResolverTests.cs | 22 +- test/dotnetup.Tests/InstallTelemetryTests.cs | 10 +- test/dotnetup.Tests/TelemetryTests.cs | 3 +- 21 files changed, 825 insertions(+), 570 deletions(-) delete mode 100644 src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs delete mode 100644 src/Installer/dotnetup/Telemetry/ExceptionInspector.cs delete mode 100644 src/Installer/dotnetup/Telemetry/VersionSanitizer.cs diff --git a/eng/restore-toolset.ps1 b/eng/restore-toolset.ps1 index c735c9fde86f..5b3fb632f218 100644 --- a/eng/restore-toolset.ps1 +++ b/eng/restore-toolset.ps1 @@ -1,74 +1,45 @@ function InitializeCustomSDKToolset { - if ($env:TestFullMSBuild -eq "true") { - $env:DOTNET_SDK_TEST_MSBUILD_PATH = InitializeVisualStudioMSBuild -install:$true -vsRequirements:$GlobalJson.tools.'vs-opt' - Write-Host "INFO: Tests will run against full MSBuild in $env:DOTNET_SDK_TEST_MSBUILD_PATH" - } - - if (-not $restore) { - return - } - - # The following frameworks and tools are used only for testing. - # Do not attempt to install them when building in the VMR. - if ($fromVmr) { - return - } - - $cli = InitializeDotnetCli -install:$true - - # Build dotnetup if not already present (needs SDK to be installed first) - EnsureDotnetupBuilt - - InstallDotNetSharedFramework "6.0" - InstallDotNetSharedFramework "7.0" - InstallDotNetSharedFramework "8.0" - InstallDotNetSharedFramework "9.0" - - CreateBuildEnvScripts - CreateVSShortcut - InstallNuget -} - -function EnsureDotnetupBuilt { - $dotnetupExe = Join-Path $PSScriptRoot "dotnetup\dotnetup.exe" - - if (!(Test-Path $dotnetupExe)) { - Write-Host "Building dotnetup..." - $dotnetupProject = Join-Path $RepoRoot "src\Installer\dotnetup\dotnetup.csproj" - $dotnetupOutDir = Join-Path $PSScriptRoot "dotnetup" - - # Determine RID based on architecture - $rid = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { - "win-arm64" - } - else { - "win-x64" - } - - & (Join-Path $env:DOTNET_INSTALL_DIR 'dotnet.exe') publish $dotnetupProject -c Release -r $rid -o $dotnetupOutDir - - if ($lastExitCode -ne 0) { - throw "Failed to build dotnetup (exit code '$lastExitCode')." - } - - Write-Host "dotnetup built successfully" - } + if ($env:TestFullMSBuild -eq "true") { + $env:DOTNET_SDK_TEST_MSBUILD_PATH = InitializeVisualStudioMSBuild -install:$true -vsRequirements:$GlobalJson.tools.'vs-opt' + Write-Host "INFO: Tests will run against full MSBuild in $env:DOTNET_SDK_TEST_MSBUILD_PATH" + } + + if (-not $restore) { + return + } + + # The following frameworks and tools are used only for testing. + # Do not attempt to install them when building in the VMR. + if ($fromVmr) { + return + } + + $cli = InitializeDotnetCli -install:$true + InstallDotNetSharedFramework "6.0.0" + InstallDotNetSharedFramework "7.0.0" + InstallDotNetSharedFramework "8.0.0" + InstallDotNetSharedFramework "9.0.0" + + CreateBuildEnvScripts + CreateVSShortcut + InstallNuget } function InstallNuGet { - $NugetInstallDir = Join-Path $ArtifactsDir ".nuget" - $NugetExe = Join-Path $NugetInstallDir "nuget.exe" + $NugetInstallDir = Join-Path $ArtifactsDir ".nuget" + $NugetExe = Join-Path $NugetInstallDir "nuget.exe" - if (!(Test-Path -Path $NugetExe)) { - Create-Directory $NugetInstallDir - Invoke-WebRequest "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -UseBasicParsing -OutFile $NugetExe - } + if (!(Test-Path -Path $NugetExe)) { + Create-Directory $NugetInstallDir + Invoke-WebRequest "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -UseBasicParsing -OutFile $NugetExe + } } -function CreateBuildEnvScripts() { - Create-Directory $ArtifactsDir - $scriptPath = Join-Path $ArtifactsDir "sdk-build-env.bat" - $scriptContents = @" +function CreateBuildEnvScripts() +{ + Create-Directory $ArtifactsDir + $scriptPath = Join-Path $ArtifactsDir "sdk-build-env.bat" + $scriptContents = @" @echo off title SDK Build ($RepoRoot) set DOTNET_MULTILEVEL_LOOKUP=0 @@ -85,11 +56,11 @@ set DOTNET_ADD_GLOBAL_TOOLS_TO_PATH=0 DOSKEY killdotnet=taskkill /F /IM dotnet.exe /T ^& taskkill /F /IM VSTest.Console.exe /T ^& taskkill /F /IM msbuild.exe /T "@ - Out-File -FilePath $scriptPath -InputObject $scriptContents -Encoding ASCII + Out-File -FilePath $scriptPath -InputObject $scriptContents -Encoding ASCII - Create-Directory $ArtifactsDir - $scriptPath = Join-Path $ArtifactsDir "sdk-build-env.ps1" - $scriptContents = @" + Create-Directory $ArtifactsDir + $scriptPath = Join-Path $ArtifactsDir "sdk-build-env.ps1" + $scriptContents = @" `$host.ui.RawUI.WindowTitle = "SDK Build ($RepoRoot)" `$env:DOTNET_MULTILEVEL_LOOKUP=0 # https://aka.ms/vs/unsigned-dotnet-debugger-lib @@ -109,85 +80,89 @@ function killdotnet { } "@ - Out-File -FilePath $scriptPath -InputObject $scriptContents -Encoding ASCII + Out-File -FilePath $scriptPath -InputObject $scriptContents -Encoding ASCII } -function CreateVSShortcut() { - # https://github.com/microsoft/vswhere/wiki/Installing - $installerPath = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" - if (-Not (Test-Path -Path $installerPath)) { - return - } - - $versionFilePath = Join-Path $RepoRoot 'src\Layout\redist\minimumMSBuildVersion' - # Gets the first digit (ex. 17) and appends '.0' to it. - $vsMajorVersion = "$(((Get-Content $versionFilePath).Split('.'))[0]).0" - $devenvPath = (& "$installerPath\vswhere.exe" -all -prerelease -latest -version $vsMajorVersion -find Common7\IDE\devenv.exe) | Select-Object -First 1 - if (-Not $devenvPath) { - return - } - - $scriptPath = Join-Path $ArtifactsDir 'sdk-build-env.ps1' - $slnPath = Join-Path $RepoRoot 'sdk.slnx' - $commandToLaunch = "& '$scriptPath'; & '$devenvPath' '$slnPath'" - $powershellPath = '%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe' - $shortcutPath = Join-Path $ArtifactsDir 'VS with sdk.slnx.lnk' - - # https://stackoverflow.com/a/9701907/294804 - # https://learn.microsoft.com/en-us/troubleshoot/windows-client/admin-development/create-desktop-shortcut-with-wsh - $wsShell = New-Object -ComObject WScript.Shell - $shortcut = $wsShell.CreateShortcut($shortcutPath) - $shortcut.TargetPath = $powershellPath - $shortcut.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -Command ""$commandToLaunch""" - $shortcut.IconLocation = $devenvPath - $shortcut.WindowStyle = 7 # Minimized - $shortcut.Save() +function CreateVSShortcut() +{ + # https://github.com/microsoft/vswhere/wiki/Installing + $installerPath = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" + if(-Not (Test-Path -Path $installerPath)) + { + return + } + + $versionFilePath = Join-Path $RepoRoot 'src\Layout\redist\minimumMSBuildVersion' + # Gets the first digit (ex. 17) and appends '.0' to it. + $vsMajorVersion = "$(((Get-Content $versionFilePath).Split('.'))[0]).0" + $devenvPath = (& "$installerPath\vswhere.exe" -all -prerelease -latest -version $vsMajorVersion -find Common7\IDE\devenv.exe) | Select-Object -First 1 + if(-Not $devenvPath) + { + return + } + + $scriptPath = Join-Path $ArtifactsDir 'sdk-build-env.ps1' + $slnPath = Join-Path $RepoRoot 'sdk.slnx' + $commandToLaunch = "& '$scriptPath'; & '$devenvPath' '$slnPath'" + $powershellPath = '%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe' + $shortcutPath = Join-Path $ArtifactsDir 'VS with sdk.slnx.lnk' + + # https://stackoverflow.com/a/9701907/294804 + # https://learn.microsoft.com/en-us/troubleshoot/windows-client/admin-development/create-desktop-shortcut-with-wsh + $wsShell = New-Object -ComObject WScript.Shell + $shortcut = $wsShell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $powershellPath + $shortcut.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -Command ""$commandToLaunch""" + $shortcut.IconLocation = $devenvPath + $shortcut.WindowStyle = 7 # Minimized + $shortcut.Save() } function InstallDotNetSharedFramework([string]$version) { - $dotnetRoot = $env:DOTNET_INSTALL_DIR - $fxDir = Join-Path $dotnetRoot "shared\Microsoft.NETCore.App\$version" - - if (!(Test-Path $fxDir)) { - $dotnetupExe = Join-Path $PSScriptRoot "dotnetup\dotnetup.exe" + $dotnetRoot = $env:DOTNET_INSTALL_DIR + $fxDir = Join-Path $dotnetRoot "shared\Microsoft.NETCore.App\$version" - & $dotnetupExe runtime install "$version" --install-path $dotnetRoot --no-progress --set-default-install false + if (!(Test-Path $fxDir)) { + $installScript = GetDotNetInstallScript $dotnetRoot + & $installScript -Version $version -InstallDir $dotnetRoot -Runtime "dotnet" -SkipNonVersionedFiles - if ($lastExitCode -ne 0) { - throw "Failed to install shared Framework $version to '$dotnetRoot' using dotnetup (exit code '$lastExitCode')." - } + if($lastExitCode -ne 0) { + throw "Failed to install shared Framework $version to '$dotnetRoot' (exit code '$lastExitCode')." } + } } # Let's clear out the stage-zero folders that map to the current runtime to keep stage 2 clean function CleanOutStage0ToolsetsAndRuntimes { - $GlobalJson = Get-Content -Raw -Path (Join-Path $RepoRoot 'global.json') | ConvertFrom-Json - $dotnetSdkVersion = $GlobalJson.tools.dotnet - $dotnetRoot = $env:DOTNET_INSTALL_DIR - $versionPath = Join-Path $dotnetRoot '.version' - $aspnetRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared' , 'Microsoft.AspNetCore.App') - $coreRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared' , 'Microsoft.NETCore.App') - $wdRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared', 'Microsoft.WindowsDesktop.App') - $sdkPath = Join-Path $dotnetRoot 'sdk' - $majorVersion = $dotnetSdkVersion.Substring(0, 1) - - if (Test-Path($versionPath)) { - $lastInstalledSDK = Get-Content -Raw -Path ($versionPath) - if ($lastInstalledSDK -ne $dotnetSdkVersion) { - $dotnetSdkVersion | Out-File -FilePath $versionPath -NoNewline - Remove-Item (Join-Path $aspnetRuntimePath "$majorVersion.*") -Recurse - Remove-Item (Join-Path $coreRuntimePath "$majorVersion.*") -Recurse - Remove-Item (Join-Path $wdRuntimePath "$majorVersion.*") -Recurse - Remove-Item (Join-Path $sdkPath "*") -Recurse - Remove-Item (Join-Path $dotnetRoot "packs") -Recurse - Remove-Item (Join-Path $dotnetRoot "sdk-manifests") -Recurse - Remove-Item (Join-Path $dotnetRoot "templates") -Recurse - throw "Installed a new SDK, deleting existing shared frameworks and sdk folders. Please rerun build" - } - } - else { - $dotnetSdkVersion | Out-File -FilePath $versionPath -NoNewline + $GlobalJson = Get-Content -Raw -Path (Join-Path $RepoRoot 'global.json') | ConvertFrom-Json + $dotnetSdkVersion = $GlobalJson.tools.dotnet + $dotnetRoot = $env:DOTNET_INSTALL_DIR + $versionPath = Join-Path $dotnetRoot '.version' + $aspnetRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared' ,'Microsoft.AspNetCore.App') + $coreRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared' ,'Microsoft.NETCore.App') + $wdRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared', 'Microsoft.WindowsDesktop.App') + $sdkPath = Join-Path $dotnetRoot 'sdk' + $majorVersion = $dotnetSdkVersion.Substring(0,1) + + if (Test-Path($versionPath)) { + $lastInstalledSDK = Get-Content -Raw -Path ($versionPath) + if ($lastInstalledSDK -ne $dotnetSdkVersion) + { + $dotnetSdkVersion | Out-File -FilePath $versionPath -NoNewline + Remove-Item (Join-Path $aspnetRuntimePath "$majorVersion.*") -Recurse + Remove-Item (Join-Path $coreRuntimePath "$majorVersion.*") -Recurse + Remove-Item (Join-Path $wdRuntimePath "$majorVersion.*") -Recurse + Remove-Item (Join-Path $sdkPath "*") -Recurse + Remove-Item (Join-Path $dotnetRoot "packs") -Recurse + Remove-Item (Join-Path $dotnetRoot "sdk-manifests") -Recurse + Remove-Item (Join-Path $dotnetRoot "templates") -Recurse + throw "Installed a new SDK, deleting existing shared frameworks and sdk folders. Please rerun build" } + } + else + { + $dotnetSdkVersion | Out-File -FilePath $versionPath -NoNewline + } } InitializeCustomSDKToolset diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index cead11ac8117..bf6bd9337ef7 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Reflection; using System.Security.Cryptography; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.Dotnet.Installation.Internal; diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs index 110848b0b3df..9f71d50a9a54 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs @@ -18,6 +18,11 @@ public class ScopedMutex : IDisposable /// public static int TimeoutSeconds { get; set; } = 300; + /// + /// Optional callback invoked when we need to wait for the mutex (another process holds it). + /// + public static Action? OnWaitingForMutex { get; set; } + public ScopedMutex(string name) { // On Linux and Mac, "Global\" prefix doesn't work - strip it if present @@ -28,7 +33,18 @@ public ScopedMutex(string name) } _mutex = new Mutex(false, mutexName); - _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(TimeoutSeconds), false); + + // First try immediate acquisition to see if we need to wait + _hasHandle = _mutex.WaitOne(0, false); + if (!_hasHandle) + { + // Another process holds the mutex - notify caller before blocking + OnWaitingForMutex?.Invoke(); + + // Now wait for the full timeout + _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(TimeoutSeconds), false); + } + if (_hasHandle) { _holdCount.Value = _holdCount.Value + 1; diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs index 6abab35d5cd2..e414681b2ffa 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.RegularExpressions; +using Microsoft.Deployment.DotNet.Releases; namespace Microsoft.Dotnet.Installation.Internal; @@ -23,25 +24,12 @@ public static partial class VersionSanitizer public static readonly IReadOnlyList KnownPrereleaseTokens = ["preview", "rc", "rtm", "ga", "alpha", "beta", "dev", "ci", "servicing"]; /// - /// Regex pattern for valid version formats (without prerelease suffix): - /// - Major only: 8, 9, 10 - /// - Major.Minor: 8.0, 9.0, 10.0 - /// - Feature band wildcard: 8.0.1xx, 9.0.3xx, 10.0.1xx (single digit + xx) - /// - Single digit wildcard: 10.0.10x, 10.0.20x (two digits + single x) - /// - Specific version: 8.0.100, 9.0.304 - /// Note: Patch versions are max 3 digits (100-999), so wildcards are constrained: - /// - Nxx pattern: single digit (1-9) + xx for feature bands (100-999) - /// - NNx pattern: two digits (10-99) + single x for narrower ranges (100-999) + /// Regex to detect wildcard patterns: single digit followed by xx, or two digits followed by x. + /// Examples: 1xx, 2xx, 10x, 20x + /// Invalid patterns (all wildcards, too many x's, etc.) will fail ReleaseVersion parse after substitution. /// - [GeneratedRegex(@"^(\d{1,2})(\.\d{1,2})?(\.\d{1,3}|\.\d{1}xx|\.\d{2}x)?$")] - private static partial Regex BaseVersionPatternRegex(); - - /// - /// Regex pattern for prerelease suffix: hyphen followed by numbers and dots only. - /// Example: -1.24234.5 (the token part like "preview" is validated separately) - /// - [GeneratedRegex(@"^(\.\d+)+$")] - private static partial Regex PrereleaseSuffixRegex(); + [GeneratedRegex(@"^(\d{1,2})(\.\d{1,2})\.(\d{1}xx|\d{2}x)$")] + private static partial Regex WildcardPatternRegex(); /// /// Sanitizes a version or channel string for safe telemetry collection. @@ -74,58 +62,62 @@ public static string Sanitize(string? versionOrChannel) } /// - /// Checks if a version string matches a valid pattern. + /// Checks if a version string matches a valid pattern using ReleaseVersion parsing. + /// For wildcard patterns (1xx, 10x), substitutes wildcards with '00' and validates. /// private static bool IsValidVersionPattern(string version) { - // Check if there's a prerelease suffix (contains hyphen) - var hyphenIndex = version.IndexOf('-'); - if (hyphenIndex < 0) + // First check for wildcard patterns (e.g., 9.0.1xx, 10.0.20x) + if (WildcardPatternRegex().IsMatch(version)) { - // No prerelease suffix, just validate the base version - return BaseVersionPatternRegex().IsMatch(version); - } - - // Split into base version and prerelease parts - var baseVersion = version[..hyphenIndex]; - var prereleasePart = version[(hyphenIndex + 1)..]; + // Substitute wildcards with '00' to create a parseable version + // 9.0.1xx -> 9.0.100, 10.0.20x -> 10.0.200 + var normalized = version + .Replace("xx", "00", StringComparison.OrdinalIgnoreCase) + .Replace("x", "0", StringComparison.OrdinalIgnoreCase); - // Validate base version - if (!BaseVersionPatternRegex().IsMatch(baseVersion)) - { - return false; + return ReleaseVersion.TryParse(normalized, out _); } - // Validate prerelease part: must start with a known token - // Format: token[.number]* (e.g., "preview", "preview.1", "preview.1.24234.5", "rc.1") - var dotIndex = prereleasePart.IndexOf('.'); - string token; - string? suffix; - - if (dotIndex < 0) - { - token = prereleasePart; - suffix = null; - } - else + // Handle prerelease versions: validate the prerelease token is known + var hyphenIndex = version.IndexOf('-'); + if (hyphenIndex >= 0) { - token = prereleasePart[..dotIndex]; - suffix = prereleasePart[dotIndex..]; // Includes the leading dot + var baseVersion = version[..hyphenIndex]; + var prereleasePart = version[(hyphenIndex + 1)..]; + + // Base version must be valid + if (!ReleaseVersion.TryParse(baseVersion, out _)) + { + // Also try parsing the full version - ReleaseVersion may handle some prerelease formats + if (!ReleaseVersion.TryParse(version, out _)) + { + return false; + } + } + + // Validate prerelease token: must start with a known token + var dotIndex = prereleasePart.IndexOf('.'); + var token = dotIndex < 0 ? prereleasePart : prereleasePart[..dotIndex]; + + return KnownPrereleaseTokens.Contains(token, StringComparer.OrdinalIgnoreCase); } - // Token must be a known prerelease identifier - if (!KnownPrereleaseTokens.Contains(token, StringComparer.OrdinalIgnoreCase)) + // Simple version (no wildcards, no prerelease) - try to parse directly + // Also accept major-only (e.g., "8", "9", "10") and major.minor (e.g., "8.0", "9.0") + if (ReleaseVersion.TryParse(version, out _)) { - return false; + return true; } - // If there's a suffix, it must be numbers separated by dots - if (suffix != null && !PrereleaseSuffixRegex().IsMatch(suffix)) + // Check for partial versions like "8" or "8.0" which ReleaseVersion may not parse + var parts = version.Split('.'); + if (parts.Length <= 2 && parts.All(p => int.TryParse(p, out var n) && n >= 0 && n < 100)) { - return false; + return true; } - return true; + return false; } /// diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index 774e7e9bbbaa..3cd4c0ddfbf2 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -48,11 +48,6 @@ public int Execute() AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]"); return 1; } - catch (Exception ex) - { - DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); - throw; - } finally { _commandActivity?.SetTag(TelemetryTagNames.ExitCode, _exitCode); diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index b48b52237d19..a6b5bc7f6852 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -61,12 +61,14 @@ private int SetUserInstallRoot() else { Console.Error.WriteLine("Error: Non-Windows platforms not yet supported"); + RecordFailure("platform_not_supported", category: "user"); return 1; } } catch (Exception ex) { Console.Error.WriteLine($"Error: Failed to configure user install root: {ex.ToString()}"); + RecordFailure("user_install_root_failed"); return 1; } } @@ -102,12 +104,14 @@ private int SetAdminInstallRoot() else { Console.Error.WriteLine("Error: Admin install root is only supported on Windows."); + RecordFailure("platform_not_supported", category: "user"); return 1; } } catch (Exception ex) { Console.Error.WriteLine($"Error: Failed to configure admin install root: {ex.ToString()}"); + RecordFailure("admin_install_root_failed"); return 1; } } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs index fcdece72d8df..3b3210e44a4b 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -18,6 +18,8 @@ public PrintEnvScriptCommand(ParseResult result, IDotnetInstallManager? dotnetIn _dotnetInstallPath = result.GetValue(PrintEnvScriptCommandParser.DotnetInstallPathOption); } + protected override string GetCommandName() => "print-env-script"; + protected override int ExecuteCore() { try diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs index 004b23823bee..11bd8c75056d 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs @@ -41,6 +41,7 @@ protected override int ExecuteCore() if (errorMessage != null) { Console.Error.WriteLine(errorMessage); + RecordFailure("invalid_component_spec", category: "user"); return 1; } @@ -49,6 +50,7 @@ protected override int ExecuteCore() { Console.Error.WriteLine("Error: Windows Desktop Runtime is only available on Windows."); Console.Error.WriteLine($"Valid component types for this platform are: {string.Join(", ", GetValidRuntimeTypes())}"); + RecordFailure("windowsdesktop_not_supported", category: "user"); return 1; } @@ -57,6 +59,7 @@ protected override int ExecuteCore() { Console.Error.WriteLine($"Error: '{versionOrChannel}' looks like an SDK version or feature band, which is not valid for runtime installations."); Console.Error.WriteLine("Use a version channel like '9.0', 'latest', 'lts', or a specific runtime version like '9.0.12'."); + RecordFailure("sdk_version_for_runtime", category: "user"); return 1; } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index bc6f570fac61..958cfd8136e6 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -14,8 +14,12 @@ internal class InstallExecutor { /// /// Result of an installation execution. + /// Success is computed from whether Install is non-null to avoid sync issues. /// - public record InstallResult(bool Success, DotnetInstall? Install, bool WasAlreadyInstalled = false); + public record InstallResult(DotnetInstall? Install, bool WasAlreadyInstalled = false) + { + public bool Success => Install is not null; + } /// /// Result of creating and resolving an install request. @@ -77,7 +81,7 @@ public static InstallResult ExecuteInstall( if (installResult.Install == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install {componentDescription} {resolvedVersion}[/]"); - return new InstallResult(false, null); + return new InstallResult(null); } if (installResult.WasAlreadyInstalled) @@ -89,7 +93,7 @@ public static InstallResult ExecuteInstall( SpectreAnsiConsole.MarkupLine($"[green]Installed {componentDescription} {installResult.Install.Version}, available via {installResult.Install.InstallRoot}[/]"); } - return new InstallResult(true, installResult.Install, installResult.WasAlreadyInstalled); + return new InstallResult(installResult.Install, installResult.WasAlreadyInstalled); } /// diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index e5b27b579c3a..9d9adf2e38e2 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -74,8 +74,8 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) if (InstallExecutor.IsAdminInstallPath(context.InstallPath)) { Console.Error.WriteLine($"Error: The install path '{context.InstallPath}' is a system-managed .NET location. " + - "dotnetup installs to user-level locations only. " + - "Use your system package manager or the official installer for system-wide installations."); + "dotnetup cannot install to the default system .NET directory (Program Files\\dotnet on Windows, /usr/share/dotnet on Linux/macOS). " + + "Use your system package manager or the official installer for system-wide installations, or choose a different path."); Activity.Current?.SetTag(TelemetryTagNames.ErrorType, "admin_path_blocked"); Activity.Current?.SetTag(TelemetryTagNames.InstallPathType, "admin"); Activity.Current?.SetTag(TelemetryTagNames.InstallPathSource, context.PathSource.ToString().ToLowerInvariant()); diff --git a/src/Installer/dotnetup/Program.cs b/src/Installer/dotnetup/Program.cs index 0bf9184be95d..4d2ab0a87b43 100644 --- a/src/Installer/dotnetup/Program.cs +++ b/src/Installer/dotnetup/Program.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -14,6 +15,12 @@ public static int Main(string[] args) // This is DEBUG-only and removes the --debug flag from args DotnetupDebugHelper.HandleDebugSwitch(ref args); + // Set up callback to notify user when waiting for another dotnetup process + ScopedMutex.OnWaitingForMutex = () => + { + Console.WriteLine("Another dotnetup process is running. Waiting for it to finish..."); + }; + // Show first-run telemetry notice if needed FirstRunNotice.ShowIfFirstRun(DotnetupTelemetry.Instance.Enabled); diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 2c8ba2cd8d3c..1e0c00861b03 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -1,7 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; @@ -19,7 +26,7 @@ public enum ErrorCategory /// /// User errors - issues caused by user input or environment we can't control. /// Examples: invalid version, disk full, network timeout, permission denied. - /// These are tracked separately and don't count against primary success rate metrics. + /// These are tracked separately and don't count against success rate. /// User } @@ -32,30 +39,22 @@ public enum ErrorCategory /// HTTP status code if applicable. /// Win32 HResult if applicable. /// Additional context (no PII - sanitized values only). -/// Full stack trace without exception messages (safe for telemetry). +/// Method name from our code where error occurred (no file paths). /// Chain of exception types for wrapped exceptions. +/// Full stack trace (safe to include - contains no PII in NativeAOT). public sealed record ExceptionErrorInfo( string ErrorType, ErrorCategory Category = ErrorCategory.Product, int? StatusCode = null, int? HResult = null, string? Details = null, - string? StackTrace = null, - string? ExceptionChain = null); + string? SourceLocation = null, + string? ExceptionChain = null, + string? StackTrace = null); /// -/// Public façade for error telemetry: maps exceptions to -/// and applies error tags to OpenTelemetry activities. +/// Maps exceptions to error info for telemetry. /// -/// -/// Implementation is split across single-responsibility helpers: -/// -/// — exception-type dispatch and enrichment -/// — Product vs User classification + HResult mapping -/// — PII-safe network exception diagnostics -/// — PII-safe stack traces and exception chains -/// -/// public static class ErrorCodeMapper { /// @@ -70,28 +69,29 @@ public static void ApplyErrorTags(Activity? activity, ExceptionErrorInfo errorIn if (activity is null) return; activity.SetStatus(ActivityStatusCode.Error, errorInfo.ErrorType); - activity.SetTag(TelemetryTagNames.ErrorType, errorInfo.ErrorType); + activity.SetTag("error.type", errorInfo.ErrorType); if (errorCode is not null) { - activity.SetTag(TelemetryTagNames.ErrorCode, errorCode); + activity.SetTag("error.code", errorCode); } - activity.SetTag(TelemetryTagNames.ErrorCategory, errorInfo.Category.ToString().ToLowerInvariant()); + activity.SetTag("error.category", errorInfo.Category.ToString().ToLowerInvariant()); // Use pattern matching to set optional tags only if they have values if (errorInfo is { StatusCode: { } statusCode }) - activity.SetTag(TelemetryTagNames.ErrorHttpStatus, statusCode); + activity.SetTag("error.http_status", statusCode); if (errorInfo is { HResult: { } hResult }) - activity.SetTag(TelemetryTagNames.ErrorHResult, hResult); + activity.SetTag("error.hresult", hResult); if (errorInfo is { Details: { } details }) - activity.SetTag(TelemetryTagNames.ErrorDetails, details); - if (errorInfo is { StackTrace: { } stackTrace }) - activity.SetTag(TelemetryTagNames.ErrorStackTrace, stackTrace); + activity.SetTag("error.details", details); + if (errorInfo is { SourceLocation: { } sourceLocation }) + activity.SetTag("error.source_location", sourceLocation); if (errorInfo is { ExceptionChain: { } exceptionChain }) - activity.SetTag(TelemetryTagNames.ErrorExceptionChain, exceptionChain); + activity.SetTag("error.exception_chain", exceptionChain); + if (errorInfo is { StackTrace: { } stackTrace }) + activity.SetTag("error.stack_trace", stackTrace); - // NOTE: We intentionally do NOT call activity.RecordException(ex) because - // exception messages can contain PII. Instead we collect stack traces with - // messages stripped via ExceptionInspector.GetStackTraceWithoutMessage. + // NOTE: We intentionally do NOT call activity.RecordException(ex) + // because exception messages/stacks can contain PII } /// @@ -99,5 +99,565 @@ public static void ApplyErrorTags(Activity? activity, ExceptionErrorInfo errorIn /// /// The exception to analyze. /// Error info with type name and contextual details. - public static ExceptionErrorInfo GetErrorInfo(Exception ex) => ExceptionErrorMapper.Map(ex); + public static ExceptionErrorInfo GetErrorInfo(Exception ex) + { + // Unwrap single-inner AggregateExceptions + if (ex is AggregateException { InnerExceptions.Count: 1 } aggEx) + { + return GetErrorInfo(aggEx.InnerExceptions[0]); + } + + // If it's a plain Exception wrapper, use the inner exception for better error type + if (ex.GetType() == typeof(Exception) && ex.InnerException is not null) + { + return GetErrorInfo(ex.InnerException); + } + + // Get common enrichment data + var sourceLocation = GetSafeSourceLocation(ex); + var exceptionChain = GetExceptionChain(ex); + var safeStackTrace = GetSafeStackTrace(ex); + + return ex switch + { + // DotnetInstallException has specific error codes - categorize by error code + // Sanitize the version to prevent PII leakage (user could have typed anything) + // For network-related errors, also check the inner exception for more details + DotnetInstallException installEx => GetInstallExceptionErrorInfo(installEx, sourceLocation, exceptionChain) with { StackTrace = safeStackTrace }, + + // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues + HttpRequestException httpEx => new ExceptionErrorInfo( + httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", + Category: GetHttpErrorCategory(httpEx.StatusCode), + StatusCode: (int?)httpEx.StatusCode, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + // FileNotFoundException before IOException (it derives from IOException) + // Could be user error (wrong path) or product error (our code referenced wrong file) + // Default to product since we should handle missing files gracefully + FileNotFoundException fnfEx => new ExceptionErrorInfo( + "FileNotFound", + Category: ErrorCategory.Product, + HResult: fnfEx.HResult, + Details: fnfEx.FileName is not null ? "file_specified" : null, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + // Permission denied - user environment issue (needs elevation or different permissions) + UnauthorizedAccessException => new ExceptionErrorInfo( + "PermissionDenied", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + // Directory not found - could be user specified bad path + DirectoryNotFoundException => new ExceptionErrorInfo( + "DirectoryNotFound", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain, safeStackTrace), + + // User cancelled the operation + OperationCanceledException => new ExceptionErrorInfo( + "Cancelled", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + // Invalid argument - user provided bad input + ArgumentException argEx => new ExceptionErrorInfo( + "InvalidArgument", + Category: ErrorCategory.User, + Details: argEx.ParamName, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + // Invalid operation - usually a bug in our code + InvalidOperationException => new ExceptionErrorInfo( + "InvalidOperation", + Category: ErrorCategory.Product, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + // Not supported - could be user trying unsupported scenario + NotSupportedException => new ExceptionErrorInfo( + "NotSupported", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + // Timeout - network/environment issue outside our control + TimeoutException => new ExceptionErrorInfo( + "Timeout", + Category: ErrorCategory.User, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace), + + // Unknown exceptions default to product (fail-safe - we should handle known cases) + _ => new ExceptionErrorInfo( + ex.GetType().Name, + Category: ErrorCategory.Product, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: safeStackTrace) + }; + } + + /// + /// Gets the error category for a DotnetInstallErrorCode. + /// + private static ErrorCategory GetInstallErrorCategory(DotnetInstallErrorCode errorCode) + { + return errorCode switch + { + // User errors - bad input or environment issues + DotnetInstallErrorCode.VersionNotFound => ErrorCategory.User, // User typed invalid version + DotnetInstallErrorCode.ReleaseNotFound => ErrorCategory.User, // User requested non-existent release + DotnetInstallErrorCode.InvalidChannel => ErrorCategory.User, // User provided bad channel format + DotnetInstallErrorCode.PermissionDenied => ErrorCategory.User, // User needs to elevate/fix permissions + DotnetInstallErrorCode.DiskFull => ErrorCategory.User, // User's disk is full + DotnetInstallErrorCode.NetworkError => ErrorCategory.User, // User's network issue + + // Product errors - issues we can take action on + DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform => ErrorCategory.Product, // Our manifest/logic issue + DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue + DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue + DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue + DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, // Server unreachable or CDN issue + DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug + DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download + DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, // Our locking mechanism issue + DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, // File system issue with our manifest + DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, // Our manifest is corrupt - we should handle + DotnetInstallErrorCode.Unknown => ErrorCategory.Product, // Unknown = assume product issue + + _ => ErrorCategory.Product // Default to product for new codes + }; + } + + /// + /// Gets error info for a DotnetInstallException, enriching with inner exception details + /// for network-related errors. + /// + private static ExceptionErrorInfo GetInstallExceptionErrorInfo( + DotnetInstallException installEx, + string? sourceLocation, + string? exceptionChain) + { + var errorCode = installEx.ErrorCode; + var baseCategory = GetInstallErrorCategory(errorCode); + var details = installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null; + int? httpStatus = null; + + // For network-related errors, check the inner exception to better categorize + // and extract additional diagnostic info + if (IsNetworkRelatedErrorCode(errorCode) && installEx.InnerException is not null) + { + var (refinedCategory, innerHttpStatus, innerDetails) = AnalyzeNetworkException(installEx.InnerException); + baseCategory = refinedCategory; + httpStatus = innerHttpStatus; + + // Combine details: version + inner exception info + if (innerDetails is not null) + { + details = details is not null ? $"{details};{innerDetails}" : innerDetails; + } + } + + return new ExceptionErrorInfo( + errorCode.ToString(), + Category: baseCategory, + StatusCode: httpStatus, + Details: details, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain); + } + + /// + /// Checks if the error code is related to network operations. + /// + private static bool IsNetworkRelatedErrorCode(DotnetInstallErrorCode errorCode) + { + return errorCode is + DotnetInstallErrorCode.ManifestFetchFailed or + DotnetInstallErrorCode.DownloadFailed or + DotnetInstallErrorCode.NetworkError; + } + + /// + /// Analyzes a network-related inner exception to determine the category and extract details. + /// + private static (ErrorCategory Category, int? HttpStatus, string? Details) AnalyzeNetworkException(Exception inner) + { + // Walk the exception chain to find HttpRequestException or SocketException + // Look for the most specific info we can find + HttpRequestException? foundHttpEx = null; + SocketException? foundSocketEx = null; + + var current = inner; + while (current is not null) + { + if (current is HttpRequestException httpEx && foundHttpEx is null) + { + foundHttpEx = httpEx; + } + + if (current is SocketException socketEx && foundSocketEx is null) + { + foundSocketEx = socketEx; + } + + current = current.InnerException; + } + + // Prefer socket-level info if available (more specific) + if (foundSocketEx is not null) + { + var socketErrorName = foundSocketEx.SocketErrorCode.ToString().ToLowerInvariant(); + return (ErrorCategory.User, null, $"socket_{socketErrorName}"); + } + + // Then HTTP-level info + if (foundHttpEx is not null) + { + var category = GetHttpErrorCategory(foundHttpEx.StatusCode); + var httpStatus = (int?)foundHttpEx.StatusCode; + + string? details = null; + if (foundHttpEx.StatusCode.HasValue) + { + details = $"http_{(int)foundHttpEx.StatusCode}"; + } + else if (foundHttpEx.HttpRequestError != HttpRequestError.Unknown) + { + // .NET 7+ has HttpRequestError enum for non-HTTP failures + details = $"request_error_{foundHttpEx.HttpRequestError.ToString().ToLowerInvariant()}"; + } + + return (category, httpStatus, details); + } + + // Couldn't determine from inner exception - use default Product category + // but mark as unknown network error + return (ErrorCategory.Product, null, "network_unknown"); + } + + /// + /// Gets the error category for an HTTP status code. + /// + private static ErrorCategory GetHttpErrorCategory(HttpStatusCode? statusCode) + { + if (!statusCode.HasValue) + { + // No status code usually means network failure - user environment + return ErrorCategory.User; + } + + var code = (int)statusCode.Value; + return code switch + { + >= 500 => ErrorCategory.Product, // 5xx server errors - our infrastructure + 404 => ErrorCategory.User, // Not found - likely user requested invalid resource + 403 => ErrorCategory.User, // Forbidden - user environment/permission issue + 401 => ErrorCategory.User, // Unauthorized - user auth issue + 408 => ErrorCategory.User, // Request timeout - user network + 429 => ErrorCategory.User, // Too many requests - user hitting rate limits + _ => ErrorCategory.Product // Other 4xx - likely our bug (bad request format, etc.) + }; + } + + private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain, string? stackTrace) + { + string errorType; + string? details; + ErrorCategory category; + + // Use HResult to derive error type consistently across all platforms + if (ioEx.HResult != 0) + { + // Use our mapping which provides consistent, readable error names + (errorType, details) = GetErrorTypeFromHResult(ioEx.HResult); + category = GetIOErrorCategory(errorType); + } + else + { + // No HResult available + errorType = "IOException"; + details = null; + category = ErrorCategory.Product; + } + + return new ExceptionErrorInfo( + errorType, + Category: category, + HResult: ioEx.HResult, + Details: details, + SourceLocation: sourceLocation, + ExceptionChain: exceptionChain, + StackTrace: stackTrace); + } + + /// + /// Gets the error category for an IO error type. + /// + private static ErrorCategory GetIOErrorCategory(string errorType) + { + return errorType switch + { + // User environment issues - we can't control these + "DiskFull" => ErrorCategory.User, + "PermissionDenied" => ErrorCategory.User, + "InvalidPath" => ErrorCategory.User, // User specified invalid path + "PathNotFound" => ErrorCategory.User, // User's directory doesn't exist + "NetworkPathNotFound" => ErrorCategory.User, // Network issue + "NetworkNameDeleted" => ErrorCategory.User, // Network issue + "DeviceFailure" => ErrorCategory.User, // Hardware issue + + // Product issues - we should handle these gracefully + "SharingViolation" => ErrorCategory.Product, // Could be our mutex/lock issue + "LockViolation" => ErrorCategory.Product, // Could be our mutex/lock issue + "PathTooLong" => ErrorCategory.Product, // We control the install path + "SemaphoreTimeout" => ErrorCategory.Product, // Could be our concurrency issue + "AlreadyExists" => ErrorCategory.Product, // We should handle existing files gracefully + "FileExists" => ErrorCategory.Product, // We should handle existing files gracefully + "FileNotFound" => ErrorCategory.Product, // Our code referenced missing file + "GeneralFailure" => ErrorCategory.Product, // Unknown IO error + "InvalidParameter" => ErrorCategory.Product, // Our code passed bad params + "IOException" => ErrorCategory.Product, // Generic IO - assume product + + _ => ErrorCategory.Product // Unknown - assume product + }; + } + + /// + /// Gets error type and human-readable details from an HResult. + /// + private static (string errorType, string? details) GetErrorTypeFromHResult(int hResult) + { + return hResult switch + { + // Disk/storage errors + unchecked((int)0x80070070) => ("DiskFull", "ERROR_DISK_FULL"), + unchecked((int)0x80070027) => ("DiskFull", "ERROR_HANDLE_DISK_FULL"), + unchecked((int)0x80070079) => ("SemaphoreTimeout", "ERROR_SEM_TIMEOUT"), + + // Permission errors + unchecked((int)0x80070005) => ("PermissionDenied", "ERROR_ACCESS_DENIED"), + unchecked((int)0x80070020) => ("SharingViolation", "ERROR_SHARING_VIOLATION"), + unchecked((int)0x80070021) => ("LockViolation", "ERROR_LOCK_VIOLATION"), + + // Path errors + unchecked((int)0x800700CE) => ("PathTooLong", "ERROR_FILENAME_EXCED_RANGE"), + unchecked((int)0x8007007B) => ("InvalidPath", "ERROR_INVALID_NAME"), + unchecked((int)0x80070003) => ("PathNotFound", "ERROR_PATH_NOT_FOUND"), + unchecked((int)0x80070002) => ("FileNotFound", "ERROR_FILE_NOT_FOUND"), + + // File/directory existence errors + unchecked((int)0x800700B7) => ("AlreadyExists", "ERROR_ALREADY_EXISTS"), + unchecked((int)0x80070050) => ("FileExists", "ERROR_FILE_EXISTS"), + + // Network errors + unchecked((int)0x80070035) => ("NetworkPathNotFound", "ERROR_BAD_NETPATH"), + unchecked((int)0x80070033) => ("NetworkNameDeleted", "ERROR_NETNAME_DELETED"), + unchecked((int)0x80004005) => ("GeneralFailure", "E_FAIL"), + + // Device/hardware errors + unchecked((int)0x8007001F) => ("DeviceFailure", "ERROR_GEN_FAILURE"), + unchecked((int)0x80070057) => ("InvalidParameter", "ERROR_INVALID_PARAMETER"), + + // Default: include raw HResult for debugging + _ => ("IOException", hResult != 0 ? $"0x{hResult:X8}" : null) + }; + } + + /// + /// Gets a safe stack trace string containing only type and method names from our assemblies. + /// In NativeAOT builds, stack traces contain no file paths or PII — only method names. + /// We filter to our own namespaces plus the immediate throw site for safety. + /// + private static string? GetSafeStackTrace(Exception ex) + { + try + { + var stackTrace = new StackTrace(ex, fNeedFileInfo: false); + var frames = stackTrace.GetFrames(); + + if (frames == null || frames.Length == 0) + { + return null; + } + + var safeFrames = new List(); + foreach (var frame in frames) + { + var methodInfo = DiagnosticMethodInfo.Create(frame); + if (methodInfo == null) continue; + + var declaringType = methodInfo.DeclaringTypeName; + if (string.IsNullOrEmpty(declaringType)) continue; + + // Only include frames from our owned namespaces + if (IsOwnedNamespace(declaringType)) + { + var typeName = ExtractTypeName(declaringType); + var lineNumber = frame.GetFileLineNumber(); + var location = $"{typeName}.{methodInfo.Name}"; + if (lineNumber > 0) + { + location += $":{lineNumber}"; + } + safeFrames.Add(location); + } + } + + return safeFrames.Count > 0 ? string.Join(" -> ", safeFrames) : null; + } + catch + { + // Never fail telemetry due to stack trace parsing + return null; + } + } + + /// + /// Gets a safe source location from the stack trace - finds the first frame from our assemblies. + /// This is typically the code in dotnetup that called into BCL/external code that threw. + /// No file paths that could contain user info. Line numbers from our code are included as they are not PII. + /// + private static string? GetSafeSourceLocation(Exception ex) + { + try + { + var stackTrace = new StackTrace(ex, fNeedFileInfo: true); + var frames = stackTrace.GetFrames(); + + if (frames == null || frames.Length == 0) + { + return null; + } + + string? throwSite = null; + + // Walk the stack from throw site upward, looking for the first frame in our code. + // This finds the dotnetup code that called into BCL/external code that threw. + foreach (var frame in frames) + { + var methodInfo = DiagnosticMethodInfo.Create(frame); + if (methodInfo == null) continue; + + // DiagnosticMethodInfo provides DeclaringTypeName which includes the full type name + var declaringType = methodInfo.DeclaringTypeName; + if (string.IsNullOrEmpty(declaringType)) continue; + + // Capture the first frame as the throw site (fallback) + if (throwSite == null) + { + var throwTypeName = ExtractTypeName(declaringType); + throwSite = $"[BCL]{throwTypeName}.{methodInfo.Name}"; + } + + // Check if it's from our assemblies by looking at the namespace prefix + if (IsOwnedNamespace(declaringType)) + { + // Extract just the type name (last part after the last dot, before any generic params) + var typeName = ExtractTypeName(declaringType); + + // Include line number for our code (not PII), but never file paths + // Also include commit SHA so line numbers can be correlated to source + var lineNumber = frame.GetFileLineNumber(); + var location = $"{typeName}.{methodInfo.Name}"; + if (lineNumber > 0) + { + location += $":{lineNumber}"; + } + return location; + } + } + + // If we didn't find our code, return the throw site as a fallback + // This code is managed by dotnetup and not the library so we expect the throwsite to only be our own dependent code we call into + return throwSite; + } + catch + { + // Never fail telemetry due to stack trace parsing + return null; + } + } + + /// + /// Checks if a type name belongs to one of our owned namespaces. + /// + private static bool IsOwnedNamespace(string declaringType) + { + return declaringType.StartsWith("Microsoft.DotNet.Tools.Bootstrapper", StringComparison.Ordinal) || + declaringType.StartsWith("Microsoft.Dotnet.Installation", StringComparison.Ordinal); + } + + /// + /// Extracts just the type name from a fully qualified type name. + /// + private static string ExtractTypeName(string fullTypeName) + { + var typeName = fullTypeName; + var lastDot = typeName.LastIndexOf('.'); + if (lastDot >= 0) + { + typeName = typeName.Substring(lastDot + 1); + } + // Remove generic arity if present (e.g., "List`1" -> "List") + var genericMarker = typeName.IndexOf('`'); + if (genericMarker >= 0) + { + typeName = typeName.Substring(0, genericMarker); + } + return typeName; + } + + /// + /// Gets the exception type chain for wrapped exceptions. + /// Example: "HttpRequestException->SocketException" + /// + private static string? GetExceptionChain(Exception ex) + { + if (ex.InnerException == null) + { + return null; + } + + try + { + var types = new List { ex.GetType().Name }; + var inner = ex.InnerException; + + // Limit depth to prevent infinite loops and overly long strings + const int maxDepth = 5; + var depth = 0; + + while (inner != null && depth < maxDepth) + { + types.Add(inner.GetType().Name); + inner = inner.InnerException; + depth++; + } + + return string.Join("->", types); + } + catch + { + return null; + } + } } diff --git a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs b/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs deleted file mode 100644 index fea58363462e..000000000000 --- a/src/Installer/dotnetup/Telemetry/ExceptionErrorMapper.cs +++ /dev/null @@ -1,178 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Dotnet.Installation; -using Microsoft.Dotnet.Installation.Internal; - -namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; - -/// -/// Maps exceptions to for telemetry. -/// Each exception type is classified with a telemetry-safe error type, -/// a , and optional PII-free details. -/// -internal static class ExceptionErrorMapper -{ - /// - /// Builds an from the given exception, - /// enriching it with source location and exception chain metadata. - /// - internal static ExceptionErrorInfo Map(Exception ex) - { - // Unwrap single-inner AggregateExceptions - if (ex is AggregateException { InnerExceptions.Count: 1 } aggEx) - { - return Map(aggEx.InnerExceptions[0]); - } - - // If it's a plain Exception wrapper, use the inner exception for better error type - if (ex.GetType() == typeof(Exception) && ex.InnerException is not null) - { - return Map(ex.InnerException); - } - - // Get common enrichment data - var stackTrace = ExceptionInspector.GetStackTraceWithoutMessage(ex); - var exceptionChain = ExceptionInspector.GetExceptionChain(ex); - - return ex switch - { - // DotnetInstallException has specific error codes — categorize by error code. - // Sanitize the version to prevent PII leakage (user could have typed anything). - // For network-related errors, also check the inner exception for more details. - DotnetInstallException installEx => MapInstallException(installEx, stackTrace, exceptionChain), - - // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues - HttpRequestException httpEx => new ExceptionErrorInfo( - httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", - Category: ErrorCategoryClassifier.ClassifyHttpError(httpEx.StatusCode), - StatusCode: (int?)httpEx.StatusCode, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - // FileNotFoundException before IOException (it derives from IOException). - // Could be user error (wrong path) or product error (our code referenced wrong file). - // Default to product since we should handle missing files gracefully. - FileNotFoundException fnfEx => new ExceptionErrorInfo( - "FileNotFound", - Category: ErrorCategory.Product, - HResult: fnfEx.HResult, - Details: fnfEx.FileName is not null ? "file_specified" : null, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - // Permission denied — user environment issue (needs elevation or different permissions) - UnauthorizedAccessException => new ExceptionErrorInfo( - "PermissionDenied", - Category: ErrorCategory.User, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - // Directory not found — could be user specified bad path - DirectoryNotFoundException => new ExceptionErrorInfo( - "DirectoryNotFound", - Category: ErrorCategory.User, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - IOException ioEx => MapIOException(ioEx, stackTrace, exceptionChain), - - // User cancelled the operation - OperationCanceledException => new ExceptionErrorInfo( - "Cancelled", - Category: ErrorCategory.User, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - // Invalid argument — likely a bug in our code (arguments are set programmatically) - ArgumentException argEx => new ExceptionErrorInfo( - "InvalidArgument", - Category: ErrorCategory.Product, - Details: argEx.ParamName, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - // Invalid operation — usually a bug in our code - InvalidOperationException => new ExceptionErrorInfo( - "InvalidOperation", - Category: ErrorCategory.Product, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - // Not supported — likely a product issue (missing implementation or unsupported code path) - NotSupportedException => new ExceptionErrorInfo( - "NotSupported", - Category: ErrorCategory.Product, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - // Timeout — network/environment issue outside our control - TimeoutException => new ExceptionErrorInfo( - "Timeout", - Category: ErrorCategory.User, - StackTrace: stackTrace, - ExceptionChain: exceptionChain), - - // Unknown exceptions default to product (fail-safe — we should handle known cases) - _ => new ExceptionErrorInfo( - ex.GetType().Name, - Category: ErrorCategory.Product, - StackTrace: stackTrace, - ExceptionChain: exceptionChain) - }; - } - - /// - /// Maps a , enriching with inner exception details - /// for network-related errors. - /// - private static ExceptionErrorInfo MapInstallException( - DotnetInstallException installEx, - string? stackTrace, - string? exceptionChain) - { - var errorCode = installEx.ErrorCode; - var baseCategory = ErrorCategoryClassifier.ClassifyInstallError(errorCode); - var details = installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null; - int? httpStatus = null; - - // For network-related errors, check the inner exception to better categorize - // and extract additional diagnostic info - if (NetworkErrorAnalyzer.IsNetworkRelatedErrorCode(errorCode) && installEx.InnerException is not null) - { - var (refinedCategory, innerHttpStatus, innerDetails) = NetworkErrorAnalyzer.AnalyzeNetworkException(installEx.InnerException); - baseCategory = refinedCategory; - httpStatus = innerHttpStatus; - - // Combine details: version + inner exception info - if (innerDetails is not null) - { - details = details is not null ? $"{details};{innerDetails}" : innerDetails; - } - } - - return new ExceptionErrorInfo( - errorCode.ToString(), - Category: baseCategory, - StatusCode: httpStatus, - Details: details, - StackTrace: stackTrace, - ExceptionChain: exceptionChain); - } - - /// - /// Maps a generic using its HResult. - /// - private static ExceptionErrorInfo MapIOException(IOException ioEx, string? stackTrace, string? exceptionChain) - { - var (errorType, category, details) = ErrorCategoryClassifier.ClassifyIOErrorByHResult(ioEx.HResult); - - return new ExceptionErrorInfo( - errorType, - Category: category, - HResult: ioEx.HResult, - Details: details, - StackTrace: stackTrace, - ExceptionChain: exceptionChain); - } -} diff --git a/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs b/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs deleted file mode 100644 index 0b631aba1a5d..000000000000 --- a/src/Installer/dotnetup/Telemetry/ExceptionInspector.cs +++ /dev/null @@ -1,123 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; - -/// -/// Extracts PII-safe diagnostic metadata from exceptions for telemetry: -/// full stack traces (without exception messages) and exception-type chains. -/// -/// -/// Follows the same pattern as the .NET SDK's TelemetryFilter.ExceptionToStringWithoutMessage. -/// Exception messages are stripped because they can contain user-provided input (file paths, -/// version strings, etc.), but stack traces are safe — especially in NativeAOT where they -/// contain only method names without source file paths or line numbers. -/// -internal static class ExceptionInspector -{ - /// - /// Gets the full exception details without messages, following the SDK pattern. - /// Includes exception type names, full stack traces, and inner exception chains. - /// Messages are stripped to avoid PII; stack traces are kept as they only contain - /// type/method names (especially safe in NativeAOT). - /// - internal static string? GetStackTraceWithoutMessage(Exception ex) - { - try - { - return ExceptionToStringWithoutMessage(ex); - } - catch - { - // Never fail telemetry due to stack trace parsing - return null; - } - } - - /// - /// Gets the exception type chain for wrapped exceptions. - /// Example: "HttpRequestException->SocketException" - /// - internal static string? GetExceptionChain(Exception ex) - { - if (ex.InnerException == null) - { - return null; - } - - try - { - var types = new List { ex.GetType().Name }; - var inner = ex.InnerException; - - // Limit depth to prevent infinite loops and overly long strings - const int maxDepth = 5; - var depth = 0; - - while (inner != null && depth < maxDepth) - { - types.Add(inner.GetType().Name); - inner = inner.InnerException; - depth++; - } - - return string.Join("->", types); - } - catch - { - return null; - } - } - - /// - /// Converts an exception to string without its message, following the .NET SDK pattern. - /// For AggregateExceptions, recursively processes all inner exceptions. - /// - private static string ExceptionToStringWithoutMessage(Exception e) - { - if (e is AggregateException aggregate) - { - var text = NonAggregateExceptionToStringWithoutMessage(aggregate); - - for (int i = 0; i < aggregate.InnerExceptions.Count; i++) - { - text = string.Format("{0}{1}---> (Inner Exception #{2}) {3}{4}{5}", - text, - Environment.NewLine, - i, - ExceptionToStringWithoutMessage(aggregate.InnerExceptions[i]), - "<---", - Environment.NewLine); - } - - return text; - } - - return NonAggregateExceptionToStringWithoutMessage(e); - } - - /// - /// Converts a non-aggregate exception to string: type name + inner exceptions + stack trace. - /// Messages are intentionally omitted to avoid PII. - /// - private static string NonAggregateExceptionToStringWithoutMessage(Exception e) - { - const string EndOfInnerExceptionStackTrace = "--- End of inner exception stack trace ---"; - - var s = e.GetType().ToString(); - - if (e.InnerException != null) - { - s = s + " ---> " + ExceptionToStringWithoutMessage(e.InnerException) + Environment.NewLine + - " " + EndOfInnerExceptionStackTrace; - } - - var stackTrace = e.StackTrace; - if (stackTrace != null) - { - s += Environment.NewLine + stackTrace; - } - - return s; - } -} diff --git a/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs b/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs deleted file mode 100644 index 4a1ddeb33bd9..000000000000 --- a/src/Installer/dotnetup/Telemetry/VersionSanitizer.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -// VersionSanitizer lives in Microsoft.Dotnet.Installation.Internal. -// Files that need it should add: using Microsoft.Dotnet.Installation.Internal; - diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index 873b100c4c16..652632c75129 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -207,7 +207,7 @@ "content": { "version": "KqlItem/1.0", "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\nlet baseData = dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"]),\n command = tostring(customDimensions[\"command.name\"]),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true);\nlet latestVersion = toscalar(baseData | where isnotempty(version) | summarize arg_max(timestamp, version) | project version);\nlet targetVersion = iif(versionFilter == 'all', latestVersion, versionFilter);\nbaseData\n| where version == targetVersion\n| where isnotempty(command)\n| summarize \n Total = count(),\n Successful = countif(success == true),\n UserErrors = countif(success == false and error_category == \"user\")\n by bin(timestamp, 1d), command\n| where (Total - UserErrors) > 0\n| extend SuccessRate = 100.0 * Successful / (Total - UserErrors)\n| project timestamp, command, SuccessRate\n| render timechart", - "size": 1, + "size": 0, "title": "Product Success Rate Over Time (by Command)", "queryType": 0, "resourceType": "microsoft.insights/components", @@ -496,7 +496,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| extend error_type = coalesce(tostring(customDimensions[\"error.type\"]), \"Unknown\")\n| extend error_code = tostring(customDimensions[\"error.code\"])\n| extend display_error = iif(error_type == \"Exception\" and isnotempty(error_code) and error_code != \"Exception\", error_code, error_type)\n| where display_error != \"Unknown\"\n| summarize Count = count() by display_error\n| order by Count desc\n| render barchart", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| extend error_type = coalesce(tostring(customDimensions[\"error.type\"]), \"(no error.type)\")\n| extend error_code = tostring(customDimensions[\"error.code\"])\n| extend display_error = iif(error_type == \"Exception\" and isnotempty(error_code) and error_code != \"Exception\", error_code, error_type)\n| summarize Count = count() by display_error\n| order by Count desc\n| render barchart", "size": 1, "title": "Product Errors by Type", "queryType": 0, diff --git a/test/dotnetup.Tests/DotnetArchiveDownloaderTests.cs b/test/dotnetup.Tests/DotnetArchiveDownloaderTests.cs index edfe65584872..bcc8bbaf9ca9 100644 --- a/test/dotnetup.Tests/DotnetArchiveDownloaderTests.cs +++ b/test/dotnetup.Tests/DotnetArchiveDownloaderTests.cs @@ -9,6 +9,7 @@ using System.Reflection; using System.Security.Cryptography; using FluentAssertions; +using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Dotnetup.Tests.Utilities; using Xunit; @@ -161,7 +162,7 @@ public void VerifyFileHash_ThrowsOnMismatch() var wrongHash = "0000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000"; - var ex = Assert.Throws(() => DotnetArchiveDownloader.VerifyFileHash(filePath, wrongHash)); + var ex = Assert.Throws(() => DotnetArchiveDownloader.VerifyFileHash(filePath, wrongHash)); ex.Message.Should().Contain("File hash mismatch"); _log.WriteLine($"Exception: {ex.Message}"); } diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index 4e6322689e46..f0e602b1a6b9 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -21,7 +21,7 @@ public void GetErrorInfo_IOException_DiskFull_MapsCorrectly() Assert.Equal("DiskFull", info.ErrorType); Assert.Equal(unchecked((int)0x80070070), info.HResult); - // Details contain the symbolic Win32 error name for PII safety + // Details contain the human-readable error constant name Assert.Equal("ERROR_DISK_FULL", info.Details); } @@ -34,7 +34,7 @@ public void GetErrorInfo_IOException_SharingViolation_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("SharingViolation", info.ErrorType); - // Details contain the symbolic Win32 error name + // Details contain the human-readable error constant name Assert.Equal("ERROR_SHARING_VIOLATION", info.Details); } @@ -46,7 +46,7 @@ public void GetErrorInfo_IOException_PathTooLong_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("PathTooLong", info.ErrorType); - // Details contain the symbolic Win32 error name for PII safety + // Details contain the human-readable error constant name Assert.Equal("ERROR_FILENAME_EXCED_RANGE", info.Details); } @@ -119,33 +119,34 @@ public void GetErrorInfo_InvalidOperationException_MapsCorrectly() } [Fact] - public void GetErrorInfo_ExceptionFromOurCode_IncludesStackTrace() + public void GetErrorInfo_ExceptionFromOurCode_IncludesSourceLocation() { // Throw from a method to get a real stack trace var ex = ThrowTestException(); var info = ErrorCodeMapper.GetErrorInfo(ex); - // Stack trace is collected with messages stripped + // Source location is only populated for our owned assemblies (dotnetup, Microsoft.Dotnet.Installation) + // In tests, we won't have those on the stack, so source location will be null // The important thing is that the method doesn't throw Assert.Equal("InvalidOperation", info.ErrorType); } [Fact] - public void GetErrorInfo_StackTrace_ContainsFramesWithoutMessages() + public void GetErrorInfo_SourceLocation_FiltersToOwnedNamespaces() { - // Verify that stack trace is collected and messages are stripped + // Verify that source location filtering works by namespace prefix // We must throw and catch to get a stack trace - exceptions created with 'new' have no trace var ex = ThrowTestException(); var info = ErrorCodeMapper.GetErrorInfo(ex); - // Stack trace should be populated since we threw a real exception - Assert.NotNull(info.StackTrace); - // Should contain the method name from the stack - Assert.Contains("ThrowTestException", info.StackTrace); - // Should NOT contain the exception message (messages are stripped for PII safety) - Assert.DoesNotContain("Test exception", info.StackTrace); + // Source location should be populated since test assembly is in an owned namespace + // (Microsoft.DotNet.Tools.Bootstrapper.Tests starts with Microsoft.DotNet.Tools.Bootstrapper) + Assert.NotNull(info.SourceLocation); + // The format is "TypeName.MethodName" - no [BCL] prefix since we found owned code + Assert.DoesNotContain("[BCL]", info.SourceLocation); + Assert.Contains("ThrowTestException", info.SourceLocation); } [Fact] @@ -172,7 +173,7 @@ public void GetErrorInfo_HResultAndDetails_ForDiskFullException() Assert.Equal("DiskFull", info.ErrorType); Assert.Equal(unchecked((int)0x80070070), info.HResult); - // Details contain the symbolic Win32 error name for PII safety + // Details contain the human-readable error constant name Assert.Equal("ERROR_DISK_FULL", info.Details); } @@ -215,7 +216,7 @@ public void GetErrorInfo_NetworkPathNotFound_MapsCorrectly() var info = ErrorCodeMapper.GetErrorInfo(ex); Assert.Equal("NetworkPathNotFound", info.ErrorType); - // Details contain the symbolic Win32 error name for PII safety + // Details contain the human-readable error constant name Assert.Equal("ERROR_BAD_NETPATH", info.Details); } @@ -229,7 +230,7 @@ public void GetErrorInfo_AlreadyExists_MapsCorrectly() Assert.Equal("AlreadyExists", info.ErrorType); Assert.Equal(unchecked((int)0x800700B7), info.HResult); - // Details contain the symbolic Win32 error name for PII safety + // Details contain the human-readable error constant name Assert.Equal("ERROR_ALREADY_EXISTS", info.Details); } diff --git a/test/dotnetup.Tests/InstallPathResolverTests.cs b/test/dotnetup.Tests/InstallPathResolverTests.cs index 56fc65c14e14..ea3647379069 100644 --- a/test/dotnetup.Tests/InstallPathResolverTests.cs +++ b/test/dotnetup.Tests/InstallPathResolverTests.cs @@ -47,7 +47,7 @@ public void Resolve_ExplicitPathOverridesGlobalJson() error.Should().BeNull("explicit install path should override global.json without error"); result.Should().NotBeNull(); result!.ResolvedInstallPath.Should().Be(ExplicitPath); - result.PathSource.Should().Be("explicit"); + result.PathSource.Should().Be(PathSource.Explicit); } [Fact] @@ -66,7 +66,7 @@ public void Resolve_UsesGlobalJsonPath_WhenNoExplicitPath() error.Should().BeNull(); result.Should().NotBeNull(); result!.ResolvedInstallPath.Should().Be(GlobalJsonPath); - result.PathSource.Should().Be("global_json"); + result.PathSource.Should().Be(PathSource.GlobalJson); } [Fact] @@ -84,7 +84,7 @@ public void Resolve_MatchingPathsSucceed() error.Should().BeNull(); result!.ResolvedInstallPath.Should().Be(SamePath); - result.PathSource.Should().Be("explicit"); + result.PathSource.Should().Be(PathSource.Explicit); } [Fact] @@ -101,7 +101,7 @@ public void Resolve_UsesExplicitPath_WhenNoGlobalJson() error.Should().BeNull(); result.Should().NotBeNull(); result!.ResolvedInstallPath.Should().Be(ExplicitPath); - result.PathSource.Should().Be("explicit"); + result.PathSource.Should().Be(PathSource.Explicit); } [Fact] @@ -119,7 +119,7 @@ public void Resolve_UsesDefaultPath_WhenNothingSpecified() error.Should().BeNull(); result.Should().NotBeNull(); result!.ResolvedInstallPath.Should().Be(installManager.GetDefaultDotnetInstallPath()); - result.PathSource.Should().Be("default"); + result.PathSource.Should().Be(PathSource.Default); } [Fact] @@ -138,7 +138,7 @@ public void Resolve_UsesCurrentUserInstall_WhenNoExplicitOrGlobalJson() error.Should().BeNull(); result!.ResolvedInstallPath.Should().Be("/user/dotnet"); - result.PathSource.Should().Be("existing_user_install"); + result.PathSource.Should().Be(PathSource.ExistingUserInstall); } /// @@ -159,7 +159,7 @@ public void Resolve_ExplicitPath_WithoutGlobalJson_ReturnsNonNull() error.Should().BeNull(); result.Should().NotBeNull("explicit path must work even without global.json"); result!.ResolvedInstallPath.Should().Be(ExplicitPath); - result.PathSource.Should().Be("explicit"); + result.PathSource.Should().Be(PathSource.Explicit); result.InstallPathFromGlobalJson.Should().BeNull(); } @@ -182,7 +182,7 @@ public void Resolve_ExplicitPath_TakesPrecedenceOverExistingUserInstall() error.Should().BeNull(); result!.ResolvedInstallPath.Should().Be(ExplicitPath, "explicit path should win over existing user install"); - result.PathSource.Should().Be("explicit"); + result.PathSource.Should().Be(PathSource.Explicit); } /// @@ -205,7 +205,7 @@ public void Resolve_GlobalJson_TakesPrecedenceOverExistingUserInstall() error.Should().BeNull(); result!.ResolvedInstallPath.Should().Be(GlobalJsonPath, "global.json should win over existing user install"); - result.PathSource.Should().Be("global_json"); + result.PathSource.Should().Be(PathSource.GlobalJson); } /// @@ -225,7 +225,7 @@ public void Resolve_NoInputs_ReturnsDefaultPath_NotNull() error.Should().BeNull(); result.Should().NotBeNull("default path fallback must always produce a result"); result!.ResolvedInstallPath.Should().NotBeNullOrEmpty(); - result.PathSource.Should().Be("default"); + result.PathSource.Should().Be(PathSource.Default); } /// @@ -247,7 +247,7 @@ public void Resolve_AdminInstall_FallsToDefault_NotExistingInstall() error.Should().BeNull(); result!.ResolvedInstallPath.Should().NotBe("/admin/dotnet", "admin installs should not be used as fallback"); - result.PathSource.Should().Be("default"); + result.PathSource.Should().Be(PathSource.Default); } private static GlobalJsonInfo CreateGlobalJsonInfo(string sdkPath) diff --git a/test/dotnetup.Tests/InstallTelemetryTests.cs b/test/dotnetup.Tests/InstallTelemetryTests.cs index 21ca80c2d7a0..218318319421 100644 --- a/test/dotnetup.Tests/InstallTelemetryTests.cs +++ b/test/dotnetup.Tests/InstallTelemetryTests.cs @@ -143,7 +143,7 @@ public void ClassifyInstallPath_GlobalJsonSource_UnknownPath_ReturnsGlobalJson() ? @"D:\repo\.dotnet" : "/tmp/repo/.dotnet"; - var result = InstallExecutor.ClassifyInstallPath(path, pathSource: "global_json"); + var result = InstallExecutor.ClassifyInstallPath(path, pathSource: PathSource.GlobalJson); Assert.Equal("global_json", result); } @@ -161,7 +161,7 @@ public void ClassifyInstallPath_GlobalJsonSource_KnownPath_ReturnsKnownType() var path = Path.Combine(localAppData, "dotnet"); - var result = InstallExecutor.ClassifyInstallPath(path, pathSource: "global_json"); + var result = InstallExecutor.ClassifyInstallPath(path, pathSource: PathSource.GlobalJson); if (OperatingSystem.IsWindows()) { @@ -182,7 +182,7 @@ public void ClassifyInstallPath_ExplicitSource_UnknownPath_ReturnsOther() ? @"D:\custom\dotnet" : "/tmp/custom/dotnet"; - var result = InstallExecutor.ClassifyInstallPath(path, pathSource: "explicit"); + var result = InstallExecutor.ClassifyInstallPath(path, pathSource: PathSource.Explicit); Assert.Equal("other", result); } @@ -265,7 +265,7 @@ public void ApplyErrorTags_SetsAllRequiredTags() ErrorType: "DiskFull", HResult: unchecked((int)0x80070070), StatusCode: null, - Details: "win32_error_112", + Details: "ERROR_DISK_FULL", StackTrace: "InstallExecutor.cs:42", ExceptionChain: "IOException", Category: ErrorCategory.User); @@ -277,7 +277,7 @@ public void ApplyErrorTags_SetsAllRequiredTags() Assert.Equal("DiskFull", a.GetTagItem("error.type")); Assert.Equal("user", a.GetTagItem("error.category")); Assert.Equal(unchecked((int)0x80070070), a.GetTagItem("error.hresult")); - Assert.Equal("win32_error_112", a.GetTagItem("error.details")); + Assert.Equal("ERROR_DISK_FULL", a.GetTagItem("error.details")); Assert.Equal("InstallExecutor.cs:42", a.GetTagItem("error.stack_trace")); Assert.Equal("IOException", a.GetTagItem("error.exception_chain")); } diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 99b58f370d26..09f16bfd61b7 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -107,7 +107,8 @@ public void GetCommonAttributes_OsPlatformIsValid() .ToDictionary(kv => kv.Key, kv => kv.Value); var osPlatform = attributes["os.platform"] as string; - Assert.Contains(osPlatform, new[] { "Windows", "Linux", "macOS", "Unknown" }); + // OSDescription returns the full OS description (e.g., "Microsoft Windows 10.0.26200") + Assert.False(string.IsNullOrEmpty(osPlatform)); } [Fact] From 760c689d0e3f61f5cd9dbb69735857d43e870556 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 11:28:44 -0800 Subject: [PATCH 50/59] restore-toolset merge fix This reverts the bad change in commit a857a847a2abb079e4dc396994ccaa37a9f85342. --- eng/restore-toolset.ps1 | 243 ++++++++++++++++++++++------------------ 1 file changed, 134 insertions(+), 109 deletions(-) diff --git a/eng/restore-toolset.ps1 b/eng/restore-toolset.ps1 index 5b3fb632f218..c735c9fde86f 100644 --- a/eng/restore-toolset.ps1 +++ b/eng/restore-toolset.ps1 @@ -1,45 +1,74 @@ function InitializeCustomSDKToolset { - if ($env:TestFullMSBuild -eq "true") { - $env:DOTNET_SDK_TEST_MSBUILD_PATH = InitializeVisualStudioMSBuild -install:$true -vsRequirements:$GlobalJson.tools.'vs-opt' - Write-Host "INFO: Tests will run against full MSBuild in $env:DOTNET_SDK_TEST_MSBUILD_PATH" - } - - if (-not $restore) { - return - } - - # The following frameworks and tools are used only for testing. - # Do not attempt to install them when building in the VMR. - if ($fromVmr) { - return - } - - $cli = InitializeDotnetCli -install:$true - InstallDotNetSharedFramework "6.0.0" - InstallDotNetSharedFramework "7.0.0" - InstallDotNetSharedFramework "8.0.0" - InstallDotNetSharedFramework "9.0.0" - - CreateBuildEnvScripts - CreateVSShortcut - InstallNuget + if ($env:TestFullMSBuild -eq "true") { + $env:DOTNET_SDK_TEST_MSBUILD_PATH = InitializeVisualStudioMSBuild -install:$true -vsRequirements:$GlobalJson.tools.'vs-opt' + Write-Host "INFO: Tests will run against full MSBuild in $env:DOTNET_SDK_TEST_MSBUILD_PATH" + } + + if (-not $restore) { + return + } + + # The following frameworks and tools are used only for testing. + # Do not attempt to install them when building in the VMR. + if ($fromVmr) { + return + } + + $cli = InitializeDotnetCli -install:$true + + # Build dotnetup if not already present (needs SDK to be installed first) + EnsureDotnetupBuilt + + InstallDotNetSharedFramework "6.0" + InstallDotNetSharedFramework "7.0" + InstallDotNetSharedFramework "8.0" + InstallDotNetSharedFramework "9.0" + + CreateBuildEnvScripts + CreateVSShortcut + InstallNuget +} + +function EnsureDotnetupBuilt { + $dotnetupExe = Join-Path $PSScriptRoot "dotnetup\dotnetup.exe" + + if (!(Test-Path $dotnetupExe)) { + Write-Host "Building dotnetup..." + $dotnetupProject = Join-Path $RepoRoot "src\Installer\dotnetup\dotnetup.csproj" + $dotnetupOutDir = Join-Path $PSScriptRoot "dotnetup" + + # Determine RID based on architecture + $rid = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { + "win-arm64" + } + else { + "win-x64" + } + + & (Join-Path $env:DOTNET_INSTALL_DIR 'dotnet.exe') publish $dotnetupProject -c Release -r $rid -o $dotnetupOutDir + + if ($lastExitCode -ne 0) { + throw "Failed to build dotnetup (exit code '$lastExitCode')." + } + + Write-Host "dotnetup built successfully" + } } function InstallNuGet { - $NugetInstallDir = Join-Path $ArtifactsDir ".nuget" - $NugetExe = Join-Path $NugetInstallDir "nuget.exe" + $NugetInstallDir = Join-Path $ArtifactsDir ".nuget" + $NugetExe = Join-Path $NugetInstallDir "nuget.exe" - if (!(Test-Path -Path $NugetExe)) { - Create-Directory $NugetInstallDir - Invoke-WebRequest "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -UseBasicParsing -OutFile $NugetExe - } + if (!(Test-Path -Path $NugetExe)) { + Create-Directory $NugetInstallDir + Invoke-WebRequest "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" -UseBasicParsing -OutFile $NugetExe + } } -function CreateBuildEnvScripts() -{ - Create-Directory $ArtifactsDir - $scriptPath = Join-Path $ArtifactsDir "sdk-build-env.bat" - $scriptContents = @" +function CreateBuildEnvScripts() { + Create-Directory $ArtifactsDir + $scriptPath = Join-Path $ArtifactsDir "sdk-build-env.bat" + $scriptContents = @" @echo off title SDK Build ($RepoRoot) set DOTNET_MULTILEVEL_LOOKUP=0 @@ -56,11 +85,11 @@ set DOTNET_ADD_GLOBAL_TOOLS_TO_PATH=0 DOSKEY killdotnet=taskkill /F /IM dotnet.exe /T ^& taskkill /F /IM VSTest.Console.exe /T ^& taskkill /F /IM msbuild.exe /T "@ - Out-File -FilePath $scriptPath -InputObject $scriptContents -Encoding ASCII + Out-File -FilePath $scriptPath -InputObject $scriptContents -Encoding ASCII - Create-Directory $ArtifactsDir - $scriptPath = Join-Path $ArtifactsDir "sdk-build-env.ps1" - $scriptContents = @" + Create-Directory $ArtifactsDir + $scriptPath = Join-Path $ArtifactsDir "sdk-build-env.ps1" + $scriptContents = @" `$host.ui.RawUI.WindowTitle = "SDK Build ($RepoRoot)" `$env:DOTNET_MULTILEVEL_LOOKUP=0 # https://aka.ms/vs/unsigned-dotnet-debugger-lib @@ -80,89 +109,85 @@ function killdotnet { } "@ - Out-File -FilePath $scriptPath -InputObject $scriptContents -Encoding ASCII + Out-File -FilePath $scriptPath -InputObject $scriptContents -Encoding ASCII } -function CreateVSShortcut() -{ - # https://github.com/microsoft/vswhere/wiki/Installing - $installerPath = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" - if(-Not (Test-Path -Path $installerPath)) - { - return - } - - $versionFilePath = Join-Path $RepoRoot 'src\Layout\redist\minimumMSBuildVersion' - # Gets the first digit (ex. 17) and appends '.0' to it. - $vsMajorVersion = "$(((Get-Content $versionFilePath).Split('.'))[0]).0" - $devenvPath = (& "$installerPath\vswhere.exe" -all -prerelease -latest -version $vsMajorVersion -find Common7\IDE\devenv.exe) | Select-Object -First 1 - if(-Not $devenvPath) - { - return - } - - $scriptPath = Join-Path $ArtifactsDir 'sdk-build-env.ps1' - $slnPath = Join-Path $RepoRoot 'sdk.slnx' - $commandToLaunch = "& '$scriptPath'; & '$devenvPath' '$slnPath'" - $powershellPath = '%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe' - $shortcutPath = Join-Path $ArtifactsDir 'VS with sdk.slnx.lnk' - - # https://stackoverflow.com/a/9701907/294804 - # https://learn.microsoft.com/en-us/troubleshoot/windows-client/admin-development/create-desktop-shortcut-with-wsh - $wsShell = New-Object -ComObject WScript.Shell - $shortcut = $wsShell.CreateShortcut($shortcutPath) - $shortcut.TargetPath = $powershellPath - $shortcut.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -Command ""$commandToLaunch""" - $shortcut.IconLocation = $devenvPath - $shortcut.WindowStyle = 7 # Minimized - $shortcut.Save() +function CreateVSShortcut() { + # https://github.com/microsoft/vswhere/wiki/Installing + $installerPath = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" + if (-Not (Test-Path -Path $installerPath)) { + return + } + + $versionFilePath = Join-Path $RepoRoot 'src\Layout\redist\minimumMSBuildVersion' + # Gets the first digit (ex. 17) and appends '.0' to it. + $vsMajorVersion = "$(((Get-Content $versionFilePath).Split('.'))[0]).0" + $devenvPath = (& "$installerPath\vswhere.exe" -all -prerelease -latest -version $vsMajorVersion -find Common7\IDE\devenv.exe) | Select-Object -First 1 + if (-Not $devenvPath) { + return + } + + $scriptPath = Join-Path $ArtifactsDir 'sdk-build-env.ps1' + $slnPath = Join-Path $RepoRoot 'sdk.slnx' + $commandToLaunch = "& '$scriptPath'; & '$devenvPath' '$slnPath'" + $powershellPath = '%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe' + $shortcutPath = Join-Path $ArtifactsDir 'VS with sdk.slnx.lnk' + + # https://stackoverflow.com/a/9701907/294804 + # https://learn.microsoft.com/en-us/troubleshoot/windows-client/admin-development/create-desktop-shortcut-with-wsh + $wsShell = New-Object -ComObject WScript.Shell + $shortcut = $wsShell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $powershellPath + $shortcut.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -Command ""$commandToLaunch""" + $shortcut.IconLocation = $devenvPath + $shortcut.WindowStyle = 7 # Minimized + $shortcut.Save() } function InstallDotNetSharedFramework([string]$version) { - $dotnetRoot = $env:DOTNET_INSTALL_DIR - $fxDir = Join-Path $dotnetRoot "shared\Microsoft.NETCore.App\$version" + $dotnetRoot = $env:DOTNET_INSTALL_DIR + $fxDir = Join-Path $dotnetRoot "shared\Microsoft.NETCore.App\$version" + + if (!(Test-Path $fxDir)) { + $dotnetupExe = Join-Path $PSScriptRoot "dotnetup\dotnetup.exe" - if (!(Test-Path $fxDir)) { - $installScript = GetDotNetInstallScript $dotnetRoot - & $installScript -Version $version -InstallDir $dotnetRoot -Runtime "dotnet" -SkipNonVersionedFiles + & $dotnetupExe runtime install "$version" --install-path $dotnetRoot --no-progress --set-default-install false - if($lastExitCode -ne 0) { - throw "Failed to install shared Framework $version to '$dotnetRoot' (exit code '$lastExitCode')." + if ($lastExitCode -ne 0) { + throw "Failed to install shared Framework $version to '$dotnetRoot' using dotnetup (exit code '$lastExitCode')." + } } - } } # Let's clear out the stage-zero folders that map to the current runtime to keep stage 2 clean function CleanOutStage0ToolsetsAndRuntimes { - $GlobalJson = Get-Content -Raw -Path (Join-Path $RepoRoot 'global.json') | ConvertFrom-Json - $dotnetSdkVersion = $GlobalJson.tools.dotnet - $dotnetRoot = $env:DOTNET_INSTALL_DIR - $versionPath = Join-Path $dotnetRoot '.version' - $aspnetRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared' ,'Microsoft.AspNetCore.App') - $coreRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared' ,'Microsoft.NETCore.App') - $wdRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared', 'Microsoft.WindowsDesktop.App') - $sdkPath = Join-Path $dotnetRoot 'sdk' - $majorVersion = $dotnetSdkVersion.Substring(0,1) - - if (Test-Path($versionPath)) { - $lastInstalledSDK = Get-Content -Raw -Path ($versionPath) - if ($lastInstalledSDK -ne $dotnetSdkVersion) - { - $dotnetSdkVersion | Out-File -FilePath $versionPath -NoNewline - Remove-Item (Join-Path $aspnetRuntimePath "$majorVersion.*") -Recurse - Remove-Item (Join-Path $coreRuntimePath "$majorVersion.*") -Recurse - Remove-Item (Join-Path $wdRuntimePath "$majorVersion.*") -Recurse - Remove-Item (Join-Path $sdkPath "*") -Recurse - Remove-Item (Join-Path $dotnetRoot "packs") -Recurse - Remove-Item (Join-Path $dotnetRoot "sdk-manifests") -Recurse - Remove-Item (Join-Path $dotnetRoot "templates") -Recurse - throw "Installed a new SDK, deleting existing shared frameworks and sdk folders. Please rerun build" + $GlobalJson = Get-Content -Raw -Path (Join-Path $RepoRoot 'global.json') | ConvertFrom-Json + $dotnetSdkVersion = $GlobalJson.tools.dotnet + $dotnetRoot = $env:DOTNET_INSTALL_DIR + $versionPath = Join-Path $dotnetRoot '.version' + $aspnetRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared' , 'Microsoft.AspNetCore.App') + $coreRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared' , 'Microsoft.NETCore.App') + $wdRuntimePath = [IO.Path]::Combine( $dotnetRoot, 'shared', 'Microsoft.WindowsDesktop.App') + $sdkPath = Join-Path $dotnetRoot 'sdk' + $majorVersion = $dotnetSdkVersion.Substring(0, 1) + + if (Test-Path($versionPath)) { + $lastInstalledSDK = Get-Content -Raw -Path ($versionPath) + if ($lastInstalledSDK -ne $dotnetSdkVersion) { + $dotnetSdkVersion | Out-File -FilePath $versionPath -NoNewline + Remove-Item (Join-Path $aspnetRuntimePath "$majorVersion.*") -Recurse + Remove-Item (Join-Path $coreRuntimePath "$majorVersion.*") -Recurse + Remove-Item (Join-Path $wdRuntimePath "$majorVersion.*") -Recurse + Remove-Item (Join-Path $sdkPath "*") -Recurse + Remove-Item (Join-Path $dotnetRoot "packs") -Recurse + Remove-Item (Join-Path $dotnetRoot "sdk-manifests") -Recurse + Remove-Item (Join-Path $dotnetRoot "templates") -Recurse + throw "Installed a new SDK, deleting existing shared frameworks and sdk folders. Please rerun build" + } + } + else { + $dotnetSdkVersion | Out-File -FilePath $versionPath -NoNewline } - } - else - { - $dotnetSdkVersion | Out-File -FilePath $versionPath -NoNewline - } } InitializeCustomSDKToolset From 705bea0a27a9f2992cc5136e0ab8de302d7de2b6 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 11:38:03 -0800 Subject: [PATCH 51/59] convert to else if chain for clearer code --- .../Commands/Shared/InstallPathResolver.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs index dfbf882d8a87..eb60d767797d 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs @@ -71,25 +71,24 @@ public record InstallPathResolutionResult( { return new InstallPathResolutionResult(explicitInstallPath, installPathFromGlobalJson, PathSource.Explicit); } - - if (installPathFromGlobalJson is not null) + else if (installPathFromGlobalJson is not null) { return new InstallPathResolutionResult(installPathFromGlobalJson, installPathFromGlobalJson, PathSource.GlobalJson); } - - if (currentDotnetInstallRoot is not null && currentDotnetInstallRoot.InstallType == InstallType.User) + else if (currentDotnetInstallRoot is not null && currentDotnetInstallRoot.InstallType == InstallType.User) { return new InstallPathResolutionResult(currentDotnetInstallRoot.Path, installPathFromGlobalJson, PathSource.ExistingUserInstall); } - - if (interactive) + else if (interactive) { var prompted = SpectreAnsiConsole.Prompt( new TextPrompt($"Where should we install the {componentDescription} to?") .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); return new InstallPathResolutionResult(prompted, installPathFromGlobalJson, PathSource.InteractivePrompt); } - - return new InstallPathResolutionResult(_dotnetInstaller.GetDefaultDotnetInstallPath(), installPathFromGlobalJson, PathSource.Default); + else + { + return new InstallPathResolutionResult(_dotnetInstaller.GetDefaultDotnetInstallPath(), installPathFromGlobalJson, PathSource.Default); + } } } From 1f3d59946f7679c0a41e14de626d7a234ae9dc84 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 13:39:25 -0800 Subject: [PATCH 52/59] Instruct on how to run dotnetup for telemetry testing --- .github/copilot-instructions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8ade0b05c21c..7509f29beab9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,6 +34,8 @@ dotnetup: - `dotnet build d:\sdk\src\Installer\dotnetup\dotnetup.csproj` - `dotnet test d:\sdk\test\dotnetup.Tests\dotnetup.Tests.csproj` - Do not run `dotnet build` from within the dotnetup directory as restore may fail. +- When running dotnetup directly (e.g. `dotnet run`), use the repo-local dogfood dotnet instance: + - `d:\sdk\.dotnet\dotnet run --project d:\sdk\src\Installer\dotnetup\dotnetup.csproj -- ` Output Considerations: - When considering how output should look, solicit advice from baronfel. From a2dd6014ed487e0971563782ec09f16a4cfacb73 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 13:46:29 -0800 Subject: [PATCH 53/59] workbook improvements avg sum query correction split runtime and sdk versions into 2 graphs add graph for command success rate over time remove unuseful graph --- .../dotnetup/Telemetry/dotnetup-workbook.json | 83 ++++++++++++++----- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index 652632c75129..0d8187de5123 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -206,9 +206,10 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\nlet baseData = dependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"]),\n command = tostring(customDimensions[\"command.name\"]),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true);\nlet latestVersion = toscalar(baseData | where isnotempty(version) | summarize arg_max(timestamp, version) | project version);\nlet targetVersion = iif(versionFilter == 'all', latestVersion, versionFilter);\nbaseData\n| where version == targetVersion\n| where isnotempty(command)\n| summarize \n Total = count(),\n Successful = countif(success == true),\n UserErrors = countif(success == false and error_category == \"user\")\n by bin(timestamp, 1d), command\n| where (Total - UserErrors) > 0\n| extend SuccessRate = 100.0 * Successful / (Total - UserErrors)\n| project timestamp, command, SuccessRate\n| render timechart", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"]),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| where isnotempty(version)\n| where not(success == false and error_category == \"user\")\n| summarize Total = count(), Successful = countif(success == true) by bin(timestamp, 1d), version\n| where Total > 0\n| extend SuccessRate = round(100.0 * Successful / Total, 1)\n| project timestamp, version, SuccessRate\n| order by timestamp asc, version desc\n| render timechart", "size": 0, - "title": "Product Success Rate Over Time (by Command)", + "aggregation": 4, + "title": "Product Success Rate Over Time (by Version)", "queryType": 0, "resourceType": "microsoft.insights/components", "visualization": "timechart", @@ -222,6 +223,30 @@ "customWidth": "35", "name": "success-timechart" }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n version = tostring(customDimensions[\"dotnetup.version\"]),\n error_category = tostring(customDimensions[\"error.category\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| where isnotempty(command)\n| where not(success == false and error_category == \"user\")\n| summarize Total = count(), Successful = countif(success == true) by bin(timestamp, 1d), command\n| where Total > 0\n| extend SuccessRate = round(100.0 * Successful / Total, 1)\n| project timestamp, command, SuccessRate\n| order by timestamp asc, command asc\n| render timechart", + "size": 0, + "aggregation": 4, + "title": "Product Success Rate Over Time (by Command)", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "timechart", + "chartSettings": { + "group": "command", + "createOtherGroup": null, + "showLegend": true, + "ySettings": { + "min": 0, + "max": 100 + } + } + }, + "customWidth": "35", + "name": "success-timechart-by-command" + }, { "type": 1, "content": { @@ -345,21 +370,6 @@ "customWidth": "25", "name": "success-by-os" }, - { - "type": 3, - "content": { - "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name startswith \"command/\" or name == \"download\" or name == \"extract\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend caller = coalesce(tostring(customDimensions[\"caller\"]), \"(library direct)\")\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| summarize Count = count() by caller\n| render piechart", - "size": 1, - "title": "Usage by Caller", - "noDataMessage": "No telemetry with caller tag yet.", - "queryType": 0, - "resourceType": "microsoft.insights/components", - "visualization": "piechart" - }, - "customWidth": "25", - "name": "caller-piechart" - }, { "type": 1, "content": { @@ -431,15 +441,29 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"download\" or name == \"extract\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"download.version\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(version)\n| summarize Count = count() by version\n| order by Count desc\n| take 20\n| render barchart", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"command/sdk/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| extend version = coalesce(tostring(customDimensions[\"download.version\"]), tostring(customDimensions[\"dotnet.requested\"]), \"unknown\")\n| where isnotempty(version) and version != \"unknown\"\n| summarize Count = count() by version\n| order by Count desc\n| take 20\n| render barchart", + "size": 1, + "title": "Most Installed SDK Versions", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "barchart" + }, + "customWidth": "50", + "name": "installed-sdk-versions" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"command/runtime/install\"\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| extend version = coalesce(tostring(customDimensions[\"download.version\"]), tostring(customDimensions[\"dotnet.requested\"]), \"unknown\")\n| where isnotempty(version) and version != \"unknown\"\n| summarize Count = count() by version\n| order by Count desc\n| take 20\n| render barchart", "size": 1, - "title": "Most Installed Versions", + "title": "Most Installed Runtime Versions", "queryType": 0, "resourceType": "microsoft.insights/components", "visualization": "barchart" }, "customWidth": "50", - "name": "installed-versions" + "name": "installed-runtime-versions" }, { "type": 3, @@ -524,7 +548,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n exception_chain = tostring(customDimensions[\"error.exception_chain\"]),\n hresult = tostring(customDimensions[\"error.hresult\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, error_details, stack_trace, exception_chain, hresult, os, version\n| order by timestamp desc\n| take 25", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n throw_site = tostring(customDimensions[\"error.throw_site\"]),\n stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n exception_chain = tostring(customDimensions[\"error.exception_chain\"]),\n hresult = tostring(customDimensions[\"error.hresult\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, throw_site, error_details, stack_trace, exception_chain, hresult, os, version\n| order by timestamp desc\n| take 25", "size": 1, "title": "Recent Product Failures (Detailed)", "queryType": 0, @@ -547,6 +571,21 @@ "customWidth": "50", "name": "errors-by-source-location" }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend throw_site = tostring(customDimensions[\"error.throw_site\"]),\n error_type = tostring(customDimensions[\"error.type\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| where isnotempty(throw_site)\n| summarize Count = count() by throw_site, error_type\n| order by Count desc\n| take 20\n| project ['Throw Site'] = throw_site, ['Error Type'] = error_type, Count", + "size": 1, + "title": "Product Errors by Throw Site", + "noDataMessage": "No throw site data yet. This field is populated by newer builds that emit the error.throw_site tag.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "customWidth": "50", + "name": "errors-by-throw-site" + }, { "type": 3, "content": { @@ -587,7 +626,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"user\"\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, error_details, os, version\n| order by timestamp desc\n| take 25", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n throw_site = tostring(customDimensions[\"error.throw_site\"]),\n stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n exception_chain = tostring(customDimensions[\"error.exception_chain\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"user\"\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, throw_site, error_details, stack_trace, exception_chain, os, version\n| order by timestamp desc\n| take 25", "size": 1, "title": "Recent User Errors", "noDataMessage": "No user errors recorded yet.", From 2d3273b42069aa1341398a78ffab188755589f33 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 14:20:29 -0800 Subject: [PATCH 54/59] catch unauthorized exceptions and handle them differently as product vs user errors --- .../Internal/DotnetArchiveExtractor.cs | 23 ++ .../Telemetry/ErrorCategoryClassifier.cs | 2 +- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 321 ++++++++---------- test/dotnetup.Tests/ErrorCodeMapperTests.cs | 220 ------------ 4 files changed, 167 insertions(+), 399 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index 875b5b1fc9f5..77c4d8dd7f3b 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -133,8 +133,31 @@ public void Commit() version: _resolvedVersion.ToString(), component: _request.Component.ToString()); } + catch (UnauthorizedAccessException ex) + { + // User environment issue — insufficient permissions to write to install directory + throw new DotnetInstallException( + DotnetInstallErrorCode.PermissionDenied, + $"Permission denied while extracting .NET archive for version {_resolvedVersion}: {ex.Message}", + ex, + version: _resolvedVersion.ToString(), + component: _request.Component.ToString()); + } + catch (IOException ex) + { + // IO errors during extraction (disk full, permission denied, path too long, etc.) + // Wrap as ExtractionFailed and preserve the original IOException. + // The telemetry layer classifies the inner IOException by HResult via ErrorCategoryClassifier. + throw new DotnetInstallException( + DotnetInstallErrorCode.ExtractionFailed, + $"Failed to extract .NET archive for version {_resolvedVersion}: {ex.Message}", + ex, + version: _resolvedVersion.ToString(), + component: _request.Component.ToString()); + } catch (Exception ex) { + // Genuine extraction issue we should investigate — product error throw new DotnetInstallException( DotnetInstallErrorCode.ExtractionFailed, $"Failed to extract .NET archive for version {_resolvedVersion}: {ex.Message}", diff --git a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs index c32f61624fa5..9c82610a6470 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs @@ -87,7 +87,7 @@ internal static ErrorCategory ClassifyInstallError(DotnetInstallErrorCode errorC DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform => ErrorCategory.Product, // Our manifest/logic issue DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue - DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue + DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue (inner IOException classified separately) DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, // Server unreachable or CDN issue DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 1e0c00861b03..0e1f5f74e4ca 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -39,9 +39,10 @@ public enum ErrorCategory /// HTTP status code if applicable. /// Win32 HResult if applicable. /// Additional context (no PII - sanitized values only). -/// Method name from our code where error occurred (no file paths). +/// Method name from our code where error occurred (includes file basename and line). /// Chain of exception types for wrapped exceptions. /// Full stack trace (safe to include - contains no PII in NativeAOT). +/// File name and line number where the exception was originally thrown (e.g., "DotnetArchiveExtractor.cs:139"). public sealed record ExceptionErrorInfo( string ErrorType, ErrorCategory Category = ErrorCategory.Product, @@ -50,7 +51,8 @@ public sealed record ExceptionErrorInfo( string? Details = null, string? SourceLocation = null, string? ExceptionChain = null, - string? StackTrace = null); + string? StackTrace = null, + string? ThrowSite = null); /// /// Maps exceptions to error info for telemetry. @@ -89,6 +91,8 @@ public static void ApplyErrorTags(Activity? activity, ExceptionErrorInfo errorIn activity.SetTag("error.exception_chain", exceptionChain); if (errorInfo is { StackTrace: { } stackTrace }) activity.SetTag("error.stack_trace", stackTrace); + if (errorInfo is { ThrowSite: { } throwSite }) + activity.SetTag("error.throw_site", throwSite); // NOTE: We intentionally do NOT call activity.RecordException(ex) // because exception messages/stacks can contain PII @@ -113,17 +117,16 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) return GetErrorInfo(ex.InnerException); } - // Get common enrichment data - var sourceLocation = GetSafeSourceLocation(ex); + // Get common enrichment data (single stack walk for all three values) var exceptionChain = GetExceptionChain(ex); - var safeStackTrace = GetSafeStackTrace(ex); + var (sourceLocation, safeStackTrace, throwSite) = GetStackInfo(ex); return ex switch { // DotnetInstallException has specific error codes - categorize by error code // Sanitize the version to prevent PII leakage (user could have typed anything) // For network-related errors, also check the inner exception for more details - DotnetInstallException installEx => GetInstallExceptionErrorInfo(installEx, sourceLocation, exceptionChain) with { StackTrace = safeStackTrace }, + DotnetInstallException installEx => GetInstallExceptionErrorInfo(installEx, sourceLocation, exceptionChain) with { StackTrace = safeStackTrace, ThrowSite = throwSite }, // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues HttpRequestException httpEx => new ExceptionErrorInfo( @@ -132,7 +135,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) StatusCode: (int?)httpEx.StatusCode, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), // FileNotFoundException before IOException (it derives from IOException) // Could be user error (wrong path) or product error (our code referenced wrong file) @@ -144,7 +148,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Details: fnfEx.FileName is not null ? "file_specified" : null, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), // Permission denied - user environment issue (needs elevation or different permissions) UnauthorizedAccessException => new ExceptionErrorInfo( @@ -152,7 +157,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), // Directory not found - could be user specified bad path DirectoryNotFoundException => new ExceptionErrorInfo( @@ -160,9 +166,10 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), - IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain, safeStackTrace), + IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain, safeStackTrace, throwSite), // User cancelled the operation OperationCanceledException => new ExceptionErrorInfo( @@ -170,7 +177,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), // Invalid argument - user provided bad input ArgumentException argEx => new ExceptionErrorInfo( @@ -179,7 +187,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Details: argEx.ParamName, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), // Invalid operation - usually a bug in our code InvalidOperationException => new ExceptionErrorInfo( @@ -187,7 +196,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Category: ErrorCategory.Product, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), // Not supported - could be user trying unsupported scenario NotSupportedException => new ExceptionErrorInfo( @@ -195,7 +205,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), // Timeout - network/environment issue outside our control TimeoutException => new ExceptionErrorInfo( @@ -203,7 +214,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Category: ErrorCategory.User, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace), + StackTrace: safeStackTrace, + ThrowSite: throwSite), // Unknown exceptions default to product (fail-safe - we should handle known cases) _ => new ExceptionErrorInfo( @@ -211,7 +223,8 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Category: ErrorCategory.Product, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: safeStackTrace) + StackTrace: safeStackTrace, + ThrowSite: throwSite) }; } @@ -231,10 +244,10 @@ private static ErrorCategory GetInstallErrorCategory(DotnetInstallErrorCode erro DotnetInstallErrorCode.NetworkError => ErrorCategory.User, // User's network issue // Product errors - issues we can take action on + DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue (inner IOException classified separately) DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform => ErrorCategory.Product, // Our manifest/logic issue DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue - DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, // Server unreachable or CDN issue DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download @@ -276,6 +289,20 @@ private static ExceptionErrorInfo GetInstallExceptionErrorInfo( } } + // For extraction errors, check if the inner exception is an IOException and classify + // by HResult using the existing ErrorCategoryClassifier. This avoids duplicating + // HResult→error-type logic in the extraction layer. + if (IsIORelatedErrorCode(errorCode) && installEx.InnerException is IOException ioInner) + { + var (ioErrorType, ioCategory, ioDetails) = ErrorCategoryClassifier.ClassifyIOErrorByHResult(ioInner.HResult); + baseCategory = ioCategory; + + if (ioDetails is not null) + { + details = details is not null ? $"{details};{ioDetails}" : ioDetails; + } + } + return new ExceptionErrorInfo( errorCode.ToString(), Category: baseCategory, @@ -296,6 +323,17 @@ DotnetInstallErrorCode.DownloadFailed or DotnetInstallErrorCode.NetworkError; } + /// + /// Checks if the error code is related to IO operations where the inner exception + /// may be an IOException that should be classified by HResult. + /// + private static bool IsIORelatedErrorCode(DotnetInstallErrorCode errorCode) + { + return errorCode is + DotnetInstallErrorCode.ExtractionFailed or + DotnetInstallErrorCode.LocalManifestError; + } + /// /// Analyzes a network-related inner exception to determine the category and extract details. /// @@ -378,26 +416,10 @@ private static ErrorCategory GetHttpErrorCategory(HttpStatusCode? statusCode) }; } - private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain, string? stackTrace) + private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain, string? stackTrace, string? throwSite) { - string errorType; - string? details; - ErrorCategory category; - - // Use HResult to derive error type consistently across all platforms - if (ioEx.HResult != 0) - { - // Use our mapping which provides consistent, readable error names - (errorType, details) = GetErrorTypeFromHResult(ioEx.HResult); - category = GetIOErrorCategory(errorType); - } - else - { - // No HResult available - errorType = "IOException"; - details = null; - category = ErrorCategory.Product; - } + // Delegate to the single-lookup classifier to avoid duplicating HResult→category logic + var (errorType, category, details) = ErrorCategoryClassifier.ClassifyIOErrorByHResult(ioEx.HResult); return new ExceptionErrorInfo( errorType, @@ -406,195 +428,138 @@ private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourc Details: details, SourceLocation: sourceLocation, ExceptionChain: exceptionChain, - StackTrace: stackTrace); + StackTrace: stackTrace, + ThrowSite: throwSite); } - /// - /// Gets the error category for an IO error type. - /// - private static ErrorCategory GetIOErrorCategory(string errorType) - { - return errorType switch - { - // User environment issues - we can't control these - "DiskFull" => ErrorCategory.User, - "PermissionDenied" => ErrorCategory.User, - "InvalidPath" => ErrorCategory.User, // User specified invalid path - "PathNotFound" => ErrorCategory.User, // User's directory doesn't exist - "NetworkPathNotFound" => ErrorCategory.User, // Network issue - "NetworkNameDeleted" => ErrorCategory.User, // Network issue - "DeviceFailure" => ErrorCategory.User, // Hardware issue - - // Product issues - we should handle these gracefully - "SharingViolation" => ErrorCategory.Product, // Could be our mutex/lock issue - "LockViolation" => ErrorCategory.Product, // Could be our mutex/lock issue - "PathTooLong" => ErrorCategory.Product, // We control the install path - "SemaphoreTimeout" => ErrorCategory.Product, // Could be our concurrency issue - "AlreadyExists" => ErrorCategory.Product, // We should handle existing files gracefully - "FileExists" => ErrorCategory.Product, // We should handle existing files gracefully - "FileNotFound" => ErrorCategory.Product, // Our code referenced missing file - "GeneralFailure" => ErrorCategory.Product, // Unknown IO error - "InvalidParameter" => ErrorCategory.Product, // Our code passed bad params - "IOException" => ErrorCategory.Product, // Generic IO - assume product - - _ => ErrorCategory.Product // Unknown - assume product - }; - } - /// - /// Gets error type and human-readable details from an HResult. - /// - private static (string errorType, string? details) GetErrorTypeFromHResult(int hResult) - { - return hResult switch - { - // Disk/storage errors - unchecked((int)0x80070070) => ("DiskFull", "ERROR_DISK_FULL"), - unchecked((int)0x80070027) => ("DiskFull", "ERROR_HANDLE_DISK_FULL"), - unchecked((int)0x80070079) => ("SemaphoreTimeout", "ERROR_SEM_TIMEOUT"), - - // Permission errors - unchecked((int)0x80070005) => ("PermissionDenied", "ERROR_ACCESS_DENIED"), - unchecked((int)0x80070020) => ("SharingViolation", "ERROR_SHARING_VIOLATION"), - unchecked((int)0x80070021) => ("LockViolation", "ERROR_LOCK_VIOLATION"), - - // Path errors - unchecked((int)0x800700CE) => ("PathTooLong", "ERROR_FILENAME_EXCED_RANGE"), - unchecked((int)0x8007007B) => ("InvalidPath", "ERROR_INVALID_NAME"), - unchecked((int)0x80070003) => ("PathNotFound", "ERROR_PATH_NOT_FOUND"), - unchecked((int)0x80070002) => ("FileNotFound", "ERROR_FILE_NOT_FOUND"), - - // File/directory existence errors - unchecked((int)0x800700B7) => ("AlreadyExists", "ERROR_ALREADY_EXISTS"), - unchecked((int)0x80070050) => ("FileExists", "ERROR_FILE_EXISTS"), - - // Network errors - unchecked((int)0x80070035) => ("NetworkPathNotFound", "ERROR_BAD_NETPATH"), - unchecked((int)0x80070033) => ("NetworkNameDeleted", "ERROR_NETNAME_DELETED"), - unchecked((int)0x80004005) => ("GeneralFailure", "E_FAIL"), - - // Device/hardware errors - unchecked((int)0x8007001F) => ("DeviceFailure", "ERROR_GEN_FAILURE"), - unchecked((int)0x80070057) => ("InvalidParameter", "ERROR_INVALID_PARAMETER"), - - // Default: include raw HResult for debugging - _ => ("IOException", hResult != 0 ? $"0x{hResult:X8}" : null) - }; - } /// - /// Gets a safe stack trace string containing only type and method names from our assemblies. - /// In NativeAOT builds, stack traces contain no file paths or PII — only method names. - /// We filter to our own namespaces plus the immediate throw site for safety. + /// Extracts source location, safe stack trace, and throw site from an exception + /// in a single stack walk. This replaces three separate methods that each created + /// their own StackTrace and walked the frames independently. /// - private static string? GetSafeStackTrace(Exception ex) + /// + /// A tuple of: + /// - SourceLocation: first frame in our code (where we called into BCL that threw) + /// - StackTrace: all frames from our namespaces joined with " -> " + /// - ThrowSite: "FileName.cs:42" from the deepest frame (or first owned frame with file info) + /// + private static (string? SourceLocation, string? StackTrace, string? ThrowSite) GetStackInfo(Exception ex) { try { - var stackTrace = new StackTrace(ex, fNeedFileInfo: false); + var stackTrace = new StackTrace(ex, fNeedFileInfo: true); var frames = stackTrace.GetFrames(); if (frames == null || frames.Length == 0) { - return null; + return (null, null, null); } + string? sourceLocation = null; + string? throwSite = null; + string? bclFallbackLocation = null; var safeFrames = new List(); - foreach (var frame in frames) + + for (int i = 0; i < frames.Length; i++) { + var frame = frames[i]; var methodInfo = DiagnosticMethodInfo.Create(frame); if (methodInfo == null) continue; var declaringType = methodInfo.DeclaringTypeName; if (string.IsNullOrEmpty(declaringType)) continue; - // Only include frames from our owned namespaces - if (IsOwnedNamespace(declaringType)) + var typeName = ExtractTypeName(declaringType); + + // Throw site: get file:line from deepest frame (i == 0), falling back to first owned frame with file info + if (i == 0) { - var typeName = ExtractTypeName(declaringType); + var fileName = GetSafeFileName(frame); var lineNumber = frame.GetFileLineNumber(); - var location = $"{typeName}.{methodInfo.Name}"; - if (lineNumber > 0) + if (fileName != null && lineNumber > 0) { - location += $":{lineNumber}"; + throwSite = $"{fileName}:{lineNumber}"; } + } + + // Source location fallback: first frame of any kind (BCL prefix) + if (bclFallbackLocation == null) + { + bclFallbackLocation = $"[BCL]{typeName}.{methodInfo.Name}"; + } + + if (IsOwnedNamespace(declaringType)) + { + var location = FormatFrameLocation(typeName, methodInfo.Name, frame); safeFrames.Add(location); + + // Source location: first owned frame + sourceLocation ??= location; + + // Throw site fallback: first owned frame with file info + if (throwSite == null) + { + var fn = GetSafeFileName(frame); + var ln = frame.GetFileLineNumber(); + if (fn != null && ln > 0) + { + throwSite = $"{fn}:{ln}"; + } + } } } - return safeFrames.Count > 0 ? string.Join(" -> ", safeFrames) : null; + // If no owned frame found, use the BCL fallback for source location + sourceLocation ??= bclFallbackLocation; + + var traceString = safeFrames.Count > 0 ? string.Join(" -> ", safeFrames) : null; + return (sourceLocation, traceString, throwSite); } catch { // Never fail telemetry due to stack trace parsing - return null; + return (null, null, null); } } /// - /// Gets a safe source location from the stack trace - finds the first frame from our assemblies. - /// This is typically the code in dotnetup that called into BCL/external code that threw. - /// No file paths that could contain user info. Line numbers from our code are included as they are not PII. + /// Formats a stack frame location as "FileName.cs:TypeName.Method:42" or "TypeName.Method:42". + /// Includes the source file basename when available for quick identification. /// - private static string? GetSafeSourceLocation(Exception ex) + private static string FormatFrameLocation(string typeName, string methodName, StackFrame frame) { - try - { - var stackTrace = new StackTrace(ex, fNeedFileInfo: true); - var frames = stackTrace.GetFrames(); + var lineNumber = frame.GetFileLineNumber(); + var fileName = GetSafeFileName(frame); - if (frames == null || frames.Length == 0) - { - return null; - } - - string? throwSite = null; - - // Walk the stack from throw site upward, looking for the first frame in our code. - // This finds the dotnetup code that called into BCL/external code that threw. - foreach (var frame in frames) - { - var methodInfo = DiagnosticMethodInfo.Create(frame); - if (methodInfo == null) continue; - - // DiagnosticMethodInfo provides DeclaringTypeName which includes the full type name - var declaringType = methodInfo.DeclaringTypeName; - if (string.IsNullOrEmpty(declaringType)) continue; + var location = fileName != null + ? $"{fileName}:{typeName}.{methodName}" + : $"{typeName}.{methodName}"; - // Capture the first frame as the throw site (fallback) - if (throwSite == null) - { - var throwTypeName = ExtractTypeName(declaringType); - throwSite = $"[BCL]{throwTypeName}.{methodInfo.Name}"; - } - - // Check if it's from our assemblies by looking at the namespace prefix - if (IsOwnedNamespace(declaringType)) - { - // Extract just the type name (last part after the last dot, before any generic params) - var typeName = ExtractTypeName(declaringType); + if (lineNumber > 0) + { + location += $":{lineNumber}"; + } - // Include line number for our code (not PII), but never file paths - // Also include commit SHA so line numbers can be correlated to source - var lineNumber = frame.GetFileLineNumber(); - var location = $"{typeName}.{methodInfo.Name}"; - if (lineNumber > 0) - { - location += $":{lineNumber}"; - } - return location; - } - } + return location; + } - // If we didn't find our code, return the throw site as a fallback - // This code is managed by dotnetup and not the library so we expect the throwsite to only be our own dependent code we call into - return throwSite; - } - catch + /// + /// Extracts just the file name (no path) from a stack frame. + /// Returns null if no file info is available. + /// File names from our own build are safe (not PII) — they're paths from the build machine. + /// + private static string? GetSafeFileName(StackFrame frame) + { + var filePath = frame.GetFileName(); + if (string.IsNullOrEmpty(filePath)) { - // Never fail telemetry due to stack trace parsing return null; } + + // Strip to basename only — no directory paths + return Path.GetFileName(filePath); } /// diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index f0e602b1a6b9..b5f1da2ba333 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -234,226 +234,6 @@ public void GetErrorInfo_AlreadyExists_MapsCorrectly() Assert.Equal("ERROR_ALREADY_EXISTS", info.Details); } - // Error category tests - [Theory] - [InlineData(DotnetInstallErrorCode.VersionNotFound, ErrorCategory.User)] - [InlineData(DotnetInstallErrorCode.ReleaseNotFound, ErrorCategory.User)] - [InlineData(DotnetInstallErrorCode.InvalidChannel, ErrorCategory.User)] - [InlineData(DotnetInstallErrorCode.PermissionDenied, ErrorCategory.User)] - [InlineData(DotnetInstallErrorCode.DiskFull, ErrorCategory.User)] - [InlineData(DotnetInstallErrorCode.NetworkError, ErrorCategory.User)] - [InlineData(DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.DownloadFailed, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.HashMismatch, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.ExtractionFailed, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.ManifestFetchFailed, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.ManifestParseFailed, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.ArchiveCorrupted, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.InstallationLocked, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.LocalManifestError, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.LocalManifestCorrupted, ErrorCategory.Product)] - [InlineData(DotnetInstallErrorCode.Unknown, ErrorCategory.Product)] - public void GetErrorInfo_DotnetInstallException_HasCorrectCategory(DotnetInstallErrorCode errorCode, ErrorCategory expectedCategory) - { - var ex = new DotnetInstallException(errorCode, "Test message"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(expectedCategory, info.Category); - } - - [Fact] - public void GetErrorInfo_UnauthorizedAccessException_IsUserError() - { - var ex = new UnauthorizedAccessException("Access denied"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - } - - [Fact] - public void GetErrorInfo_TimeoutException_IsUserError() - { - var ex = new TimeoutException("Operation timed out"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - } - - [Fact] - public void GetErrorInfo_ArgumentException_IsUserError() - { - var ex = new ArgumentException("Invalid argument", "testParam"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - } - - [Fact] - public void GetErrorInfo_OperationCanceledException_IsUserError() - { - var ex = new OperationCanceledException("Cancelled by user"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - } - - [Fact] - public void GetErrorInfo_InvalidOperationException_IsProductError() - { - var ex = new InvalidOperationException("Invalid state"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.Product, info.Category); - } - - [Fact] - public void GetErrorInfo_UnknownException_DefaultsToProductError() - { - var ex = new CustomTestException("Test"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.Product, info.Category); - } - - [Fact] - public void GetErrorInfo_IOException_DiskFull_IsUserError() - { - // HResult for ERROR_DISK_FULL (0x80070070 = -2147024784) - var ex = new IOException("Disk full", unchecked((int)0x80070070)); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - } - - [Fact] - public void GetErrorInfo_IOException_SharingViolation_IsProductError() - { - // HResult for ERROR_SHARING_VIOLATION (0x80070020 = -2147024864) - // Could be our mutex/lock issue - var ex = new IOException("File in use", unchecked((int)0x80070020)); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.Product, info.Category); - } - - [Fact] - public void GetErrorInfo_HttpRequestException_5xx_IsProductError() - { - var ex = new HttpRequestException("Server error", null, System.Net.HttpStatusCode.InternalServerError); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.Product, info.Category); - } - - [Fact] - public void GetErrorInfo_HttpRequestException_404_IsUserError() - { - var ex = new HttpRequestException("Not found", null, System.Net.HttpStatusCode.NotFound); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - } - - [Fact] - public void GetErrorInfo_HttpRequestException_NoStatusCode_IsUserError() - { - // No status code typically means network connectivity issue - var ex = new HttpRequestException("Network error"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - } - - [Fact] - public void GetErrorInfo_ManifestFetchFailed_WithInnerHttpException_NoStatus_IsUserError() - { - // Network connectivity failure during manifest fetch should be User, not Product - var innerEx = new HttpRequestException("Error while copying content to a stream"); - var ex = new DotnetInstallException( - DotnetInstallErrorCode.ManifestFetchFailed, - $"Failed to fetch release manifest: {innerEx.Message}", - innerEx); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - Assert.Equal("ManifestFetchFailed", info.ErrorType); - } - - [Fact] - public void GetErrorInfo_ManifestFetchFailed_WithInner500Error_IsProductError() - { - // Server errors (5xx) during manifest fetch should be Product - var innerEx = new HttpRequestException("Internal server error", null, System.Net.HttpStatusCode.InternalServerError); - var ex = new DotnetInstallException( - DotnetInstallErrorCode.ManifestFetchFailed, - $"Failed to fetch release manifest: {innerEx.Message}", - innerEx); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.Product, info.Category); - Assert.Equal(500, info.StatusCode); - Assert.Contains("http_500", info.Details!); - } - - [Fact] - public void GetErrorInfo_ManifestFetchFailed_WithInnerSocketException_IsUserError() - { - // Socket errors are user environment issues - var socketEx = new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.HostNotFound); - var httpEx = new HttpRequestException("Error", socketEx); - var ex = new DotnetInstallException( - DotnetInstallErrorCode.ManifestFetchFailed, - $"Failed to fetch release manifest: {httpEx.Message}", - httpEx); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - Assert.Contains("socket_", info.Details!); - } - - [Fact] - public void GetErrorInfo_DownloadFailed_WithInnerHttpException_NoStatus_IsUserError() - { - // Network connectivity failure during download should be User - var innerEx = new HttpRequestException("Network error"); - var ex = new DotnetInstallException( - DotnetInstallErrorCode.DownloadFailed, - $"Download failed: {innerEx.Message}", - innerEx); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.User, info.Category); - } - - [Fact] - public void GetErrorInfo_ManifestFetchFailed_NoInnerException_IsProductError() - { - // Without inner exception info, we can't determine it's a user issue - default to Product - var ex = new DotnetInstallException( - DotnetInstallErrorCode.ManifestFetchFailed, - "Failed to fetch release manifest"); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - Assert.Equal(ErrorCategory.Product, info.Category); - } - private class CustomTestException : Exception { public CustomTestException(string message) : base(message) { } From 0a545aa153302c3432e157c371147e5ef75f458e Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 14:41:20 -0800 Subject: [PATCH 55/59] improve telemetry notice Co-authored-by: Daniel Plaisted --- src/Installer/dotnetup/docs/dotnetup-telemetry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Installer/dotnetup/docs/dotnetup-telemetry.md b/src/Installer/dotnetup/docs/dotnetup-telemetry.md index 8bce4c3f190f..d7a1c6aec537 100644 --- a/src/Installer/dotnetup/docs/dotnetup-telemetry.md +++ b/src/Installer/dotnetup/docs/dotnetup-telemetry.md @@ -45,7 +45,7 @@ Data collected includes: If dotnetup crashes, it collects the name of the exception and the stack trace of dotnetup code, following the same approach as the .NET SDK. Exception messages -are stripped from the stack trace because they may contain user-provided input. +are not included because they may contain user-provided input. For more details on crash exception telemetry, see the [.NET CLI telemetry documentation](https://aka.ms/dotnet-cli-telemetry). From 9e280f5ded50cc11aabf8a156d18cee89fe3b1bb Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 14:42:02 -0800 Subject: [PATCH 56/59] Simplify prerelease version chk Co-authored-by: Daniel Plaisted --- .../Internal/VersionSanitizer.cs | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs index e414681b2ffa..861f38f6d75e 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs @@ -79,36 +79,18 @@ private static bool IsValidVersionPattern(string version) return ReleaseVersion.TryParse(normalized, out _); } - // Handle prerelease versions: validate the prerelease token is known - var hyphenIndex = version.IndexOf('-'); - if (hyphenIndex >= 0) + if (ReleaseVersion.TryParse(version, out ReleaseVersion releaseVersion)) { - var baseVersion = version[..hyphenIndex]; - var prereleasePart = version[(hyphenIndex + 1)..]; - - // Base version must be valid - if (!ReleaseVersion.TryParse(baseVersion, out _)) + if (releaseVersion.Prerelease != null) { - // Also try parsing the full version - ReleaseVersion may handle some prerelease formats - if (!ReleaseVersion.TryParse(version, out _)) - { - return false; - } - } + // Validate prerelease token: must start with a known token + var dotIndex = releaseVersion.Prerelease.IndexOf('.'); + var token = dotIndex < 0 ? releaseVersion.Prerelease : releaseVersion.Prerelease[..dotIndex]; - // Validate prerelease token: must start with a known token - var dotIndex = prereleasePart.IndexOf('.'); - var token = dotIndex < 0 ? prereleasePart : prereleasePart[..dotIndex]; - - return KnownPrereleaseTokens.Contains(token, StringComparer.OrdinalIgnoreCase); + return KnownPrereleaseTokens.Contains(token, StringComparer.OrdinalIgnoreCase); + } } - // Simple version (no wildcards, no prerelease) - try to parse directly - // Also accept major-only (e.g., "8", "9", "10") and major.minor (e.g., "8.0", "9.0") - if (ReleaseVersion.TryParse(version, out _)) - { - return true; - } // Check for partial versions like "8" or "8.0" which ReleaseVersion may not parse var parts = version.Split('.'); From 290ffb64516f20a8d91dd2ef38e7df6657627c49 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 15:33:34 -0800 Subject: [PATCH 57/59] PR Feedback - clean up unused, shared code --- .../IProgressTarget.cs | 27 -- .../Internal/VersionSanitizer.cs | 19 +- src/Installer/dotnetup/DotnetupPaths.cs | 2 +- .../dotnetup/NonUpdatingProgressTarget.cs | 67 +-- .../dotnetup/SpectreProgressTarget.cs | 60 +-- .../Telemetry/ErrorCategoryClassifier.cs | 186 +++++--- .../dotnetup/Telemetry/ErrorCodeMapper.cs | 432 ++---------------- .../Telemetry/NetworkErrorAnalyzer.cs | 89 ---- .../dotnetup/Telemetry/dotnetup-workbook.json | 24 +- .../ChannelVersionResolverTests.cs | 3 + test/dotnetup.Tests/ErrorCodeMapperTests.cs | 51 +-- test/dotnetup.Tests/InfoCommandTests.cs | 19 - test/dotnetup.Tests/InstallTelemetryTests.cs | 11 +- test/dotnetup.Tests/TelemetryTests.cs | 69 --- 14 files changed, 214 insertions(+), 845 deletions(-) delete mode 100644 src/Installer/dotnetup/Telemetry/NetworkErrorAnalyzer.cs diff --git a/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs b/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs index dff83c9b8bdd..32f167387691 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs @@ -11,15 +11,6 @@ public interface IProgressTarget public interface IProgressReporter : IDisposable { IProgressTask AddTask(string description, double maxValue); - - /// - /// Adds a task with telemetry activity tracking. - /// - /// The name for the telemetry activity (e.g., "download", "extract"). - /// The user-visible description. - /// The maximum progress value. - IProgressTask AddTask(string activityName, string description, double maxValue) - => AddTask(description, maxValue); // Default: no telemetry } public interface IProgressTask @@ -27,21 +18,6 @@ public interface IProgressTask string Description { get; set; } double Value { get; set; } double MaxValue { get; set; } - - /// - /// Sets a telemetry tag on the underlying activity (if any). - /// - void SetTag(string key, object? value) { } - - /// - /// Records an error on the underlying activity (if any). - /// - void RecordError(Exception ex) { } - - /// - /// Marks the task as successfully completed. - /// - void Complete() { } } public class NullProgressTarget : IProgressTarget @@ -54,9 +30,6 @@ public void Dispose() { } public IProgressTask AddTask(string description, double maxValue) => new NullProgressTask(description); - - public IProgressTask AddTask(string activityName, string description, double maxValue) - => new NullProgressTask(description); } private sealed class NullProgressTask : IProgressTask diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs index e414681b2ffa..8562a80d39bb 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs @@ -83,22 +83,17 @@ private static bool IsValidVersionPattern(string version) var hyphenIndex = version.IndexOf('-'); if (hyphenIndex >= 0) { - var baseVersion = version[..hyphenIndex]; - var prereleasePart = version[(hyphenIndex + 1)..]; - - // Base version must be valid - if (!ReleaseVersion.TryParse(baseVersion, out _)) + // ReleaseVersion handles parsing the full prerelease format + if (!ReleaseVersion.TryParse(version, out var releaseVersion) || + string.IsNullOrEmpty(releaseVersion.Prerelease)) { - // Also try parsing the full version - ReleaseVersion may handle some prerelease formats - if (!ReleaseVersion.TryParse(version, out _)) - { - return false; - } + return false; } // Validate prerelease token: must start with a known token - var dotIndex = prereleasePart.IndexOf('.'); - var token = dotIndex < 0 ? prereleasePart : prereleasePart[..dotIndex]; + var prerelease = releaseVersion.Prerelease; + var dotIndex = prerelease.IndexOf('.'); + var token = dotIndex < 0 ? prerelease : prerelease[..dotIndex]; return KnownPrereleaseTokens.Contains(token, StringComparer.OrdinalIgnoreCase); } diff --git a/src/Installer/dotnetup/DotnetupPaths.cs b/src/Installer/dotnetup/DotnetupPaths.cs index 1e4556369bba..60db1604b7b8 100644 --- a/src/Installer/dotnetup/DotnetupPaths.cs +++ b/src/Installer/dotnetup/DotnetupPaths.cs @@ -19,7 +19,7 @@ internal static class DotnetupPaths /// Gets the base data directory for dotnetup. /// On Windows: %LOCALAPPDATA%\dotnetup /// On macOS: ~/Library/Application Support/dotnetup - /// On Linux: ~/.local/share/dotnetup + /// On Linux: $XDG_DATA_HOME/dotnetup or ~/.local/share/dotnetup /// /// /// Can be overridden via DOTNET_TESTHOOK_DOTNETUP_DATA_DIR environment variable. diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index e97d9debc713..a9ca3090e072 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; -using Microsoft.Dotnet.Installation.Internal; -using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -14,47 +11,26 @@ public class NonUpdatingProgressTarget : IProgressTarget private sealed class Reporter : IProgressReporter { - private readonly List _tasks = new(); - public IProgressTask AddTask(string description, double maxValue) { - var task = new ProgressTaskImpl(description, activity: null) { MaxValue = maxValue }; - _tasks.Add(task); - AnsiConsole.WriteLine(description + "..."); - return task; - } - - public IProgressTask AddTask(string activityName, string description, double maxValue) - { - var activity = InstallationActivitySource.ActivitySource.StartActivity(activityName, ActivityKind.Internal); - // Tag library activities so consumers know they came from dotnetup CLI - activity?.SetTag(TelemetryTagNames.Caller, "dotnetup"); - var task = new ProgressTaskImpl(description, activity) { MaxValue = maxValue }; - _tasks.Add(task); + var task = new ProgressTaskImpl(description) { MaxValue = maxValue }; AnsiConsole.WriteLine(description + "..."); return task; } public void Dispose() { - foreach (var task in _tasks) - { - task.Complete(); - task.DisposeActivity(); - } } } private sealed class ProgressTaskImpl : IProgressTask { - private readonly Activity? _activity; - private bool _completed; private double _value; + private bool _completed; - public ProgressTaskImpl(string description, Activity? activity) + public ProgressTaskImpl(string description) { Description = description; - _activity = activity; } public double Value @@ -63,46 +39,15 @@ public double Value set { _value = value; - if (_value >= MaxValue) + if (_value >= MaxValue && !_completed) { - Complete(); + _completed = true; + AnsiConsole.MarkupLine($"[green]Completed:[/] {Description}"); } } } public string Description { get; set; } public double MaxValue { get; set; } - - public void SetTag(string key, object? value) - { - _activity?.SetTag(key, value); - } - - public void RecordError(Exception ex) - { - if (_activity == null) return; - - // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) - var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - ErrorCodeMapper.ApplyErrorTags(_activity, errorInfo); - } - - public void Complete() - { - if (_completed) return; - _completed = true; - _activity?.SetStatus(ActivityStatusCode.Ok); - AnsiConsole.MarkupLine($"[green]Completed:[/] {Description}"); - } - - public void DisposeActivity() - { - // Don't print "Completed" again if already completed - if (!_completed) - { - _activity?.SetStatus(ActivityStatusCode.Unset); - } - _activity?.Dispose(); - } } } diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index ec51d656284e..230ff3d38dc8 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; -using Microsoft.Dotnet.Installation.Internal; -using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -16,7 +13,6 @@ private sealed class Reporter : IProgressReporter { private readonly TaskCompletionSource _overallTask = new(); private readonly ProgressContext _progressContext; - private readonly List _tasks = new(); public Reporter() { @@ -32,27 +28,11 @@ public Reporter() public IProgressTask AddTask(string description, double maxValue) { - var task = new ProgressTaskImpl(_progressContext.AddTask(description, maxValue: maxValue), activity: null); - _tasks.Add(task); - return task; - } - - public IProgressTask AddTask(string activityName, string description, double maxValue) - { - var activity = InstallationActivitySource.ActivitySource.StartActivity(activityName, ActivityKind.Internal); - // Tag library activities so consumers know they came from dotnetup CLI - activity?.SetTag(TelemetryTagNames.Caller, "dotnetup"); - var task = new ProgressTaskImpl(_progressContext.AddTask(description, maxValue: maxValue), activity); - _tasks.Add(task); - return task; + return new ProgressTaskImpl(_progressContext.AddTask(description, maxValue: maxValue)); } public void Dispose() { - foreach (var task in _tasks) - { - task.DisposeActivity(); - } _overallTask.SetResult(); } } @@ -60,13 +40,10 @@ public void Dispose() private sealed class ProgressTaskImpl : IProgressTask { private readonly Spectre.Console.ProgressTask _task; - private readonly Activity? _activity; - private bool _completed; - public ProgressTaskImpl(Spectre.Console.ProgressTask task, Activity? activity) + public ProgressTaskImpl(Spectre.Console.ProgressTask task) { _task = task; - _activity = activity; } public double Value @@ -86,38 +63,5 @@ public double MaxValue get => _task.MaxValue; set => _task.MaxValue = value; } - - public void SetTag(string key, object? value) - { - _activity?.SetTag(key, value); - } - - public void RecordError(Exception ex) - { - if (_activity == null) return; - - // Use ErrorCodeMapper for rich error metadata (same as command-level telemetry) - var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); - ErrorCodeMapper.ApplyErrorTags(_activity, errorInfo); - } - - public void Complete() - { - if (_completed) return; - _completed = true; - _activity?.SetStatus(ActivityStatusCode.Ok); - } - - public void DisposeActivity() - { - // Ensure Spectre task shows as complete (visually) - _task.Value = _task.MaxValue; - - if (!_completed) - { - _activity?.SetStatus(ActivityStatusCode.Unset); - } - _activity?.Dispose(); - } } } diff --git a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs index 9c82610a6470..aa4ae59e9e51 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; +using System.Net.Http; +using System.Net.Sockets; using Microsoft.Dotnet.Installation; namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; @@ -17,6 +19,134 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; /// internal static class ErrorCategoryClassifier { + /// + /// Classifies a as product or user error. + /// + internal static ErrorCategory ClassifyInstallError(DotnetInstallErrorCode errorCode) + { + return errorCode switch + { + // User errors - bad input or environment issues + DotnetInstallErrorCode.VersionNotFound => ErrorCategory.User, + DotnetInstallErrorCode.ReleaseNotFound => ErrorCategory.User, + DotnetInstallErrorCode.InvalidChannel => ErrorCategory.User, + DotnetInstallErrorCode.PermissionDenied => ErrorCategory.User, + DotnetInstallErrorCode.DiskFull => ErrorCategory.User, + DotnetInstallErrorCode.NetworkError => ErrorCategory.User, + + // Product errors - issues we can take action on + DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, + DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform => ErrorCategory.Product, + DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, + DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, + DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, + DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, + DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, + DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, + DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, + DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, + DotnetInstallErrorCode.Unknown => ErrorCategory.Product, + + _ => ErrorCategory.Product + }; + } + + /// + /// Classifies an HTTP status code as product or user error. + /// + internal static ErrorCategory ClassifyHttpError(HttpStatusCode? statusCode) + { + if (!statusCode.HasValue) + { + return ErrorCategory.User; + } + + var code = (int)statusCode.Value; + return code switch + { + >= 500 => ErrorCategory.Product, + 404 => ErrorCategory.User, + 403 => ErrorCategory.User, + 401 => ErrorCategory.User, + 408 => ErrorCategory.User, + 429 => ErrorCategory.User, + _ => ErrorCategory.Product + }; + } + + /// + /// Checks if the error code is related to network operations. + /// + internal static bool IsNetworkRelatedErrorCode(DotnetInstallErrorCode errorCode) + { + return errorCode is + DotnetInstallErrorCode.ManifestFetchFailed or + DotnetInstallErrorCode.DownloadFailed or + DotnetInstallErrorCode.NetworkError; + } + + /// + /// Checks if the error code is related to IO operations where the inner exception + /// may be an IOException that should be classified by HResult. + /// + internal static bool IsIORelatedErrorCode(DotnetInstallErrorCode errorCode) + { + return errorCode is + DotnetInstallErrorCode.ExtractionFailed or + DotnetInstallErrorCode.LocalManifestError; + } + + /// + /// Analyzes a network-related inner exception to determine the category and extract details. + /// + internal static (ErrorCategory Category, int? HttpStatus, string? Details) AnalyzeNetworkException(Exception inner) + { + HttpRequestException? foundHttpEx = null; + SocketException? foundSocketEx = null; + + var current = inner; + while (current is not null) + { + if (current is HttpRequestException httpEx && foundHttpEx is null) + { + foundHttpEx = httpEx; + } + + if (current is SocketException socketEx && foundSocketEx is null) + { + foundSocketEx = socketEx; + } + + current = current.InnerException; + } + + if (foundSocketEx is not null) + { + var socketErrorName = foundSocketEx.SocketErrorCode.ToString().ToLowerInvariant(); + return (ErrorCategory.User, null, $"socket_{socketErrorName}"); + } + + if (foundHttpEx is not null) + { + var category = ClassifyHttpError(foundHttpEx.StatusCode); + var httpStatus = (int?)foundHttpEx.StatusCode; + + string? details = null; + if (foundHttpEx.StatusCode.HasValue) + { + details = $"http_{(int)foundHttpEx.StatusCode}"; + } + else if (foundHttpEx.HttpRequestError != HttpRequestError.Unknown) + { + details = $"request_error_{foundHttpEx.HttpRequestError.ToString().ToLowerInvariant()}"; + } + + return (category, httpStatus, details); + } + + return (ErrorCategory.Product, null, "network_unknown"); + } + /// /// Classifies an IO error by its HResult, returning the error type, category, and optional details /// in a single lookup. This avoids a two-step HResult→errorType→category pipeline that could @@ -67,60 +197,4 @@ internal static (string ErrorType, ErrorCategory Category, string? Details) Clas _ => ("IOException", ErrorCategory.Product, hResult != 0 ? $"0x{hResult:X8}" : null) }; } - - /// - /// Classifies a as product or user error. - /// - internal static ErrorCategory ClassifyInstallError(DotnetInstallErrorCode errorCode) - { - return errorCode switch - { - // User errors - bad input or environment issues - DotnetInstallErrorCode.VersionNotFound => ErrorCategory.User, // User typed invalid version - DotnetInstallErrorCode.ReleaseNotFound => ErrorCategory.User, // User requested non-existent release - DotnetInstallErrorCode.InvalidChannel => ErrorCategory.User, // User provided bad channel format - DotnetInstallErrorCode.PermissionDenied => ErrorCategory.User, // User needs to elevate/fix permissions - DotnetInstallErrorCode.DiskFull => ErrorCategory.User, // User's disk is full - DotnetInstallErrorCode.NetworkError => ErrorCategory.User, // User's network issue - - // Product errors - issues we can take action on - DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform => ErrorCategory.Product, // Our manifest/logic issue - DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue - DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue - DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue (inner IOException classified separately) - DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, // Server unreachable or CDN issue - DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug - DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download - DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, // Our locking mechanism issue - DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, // File system issue with our manifest - DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, // Our manifest is corrupt - we should handle - DotnetInstallErrorCode.Unknown => ErrorCategory.Product, // Unknown = assume product issue - - _ => ErrorCategory.Product // Default to product for new codes - }; - } - - /// - /// Classifies an HTTP status code as product or user error. - /// - internal static ErrorCategory ClassifyHttpError(HttpStatusCode? statusCode) - { - if (!statusCode.HasValue) - { - // No status code usually means network failure - user environment - return ErrorCategory.User; - } - - var code = (int)statusCode.Value; - return code switch - { - >= 500 => ErrorCategory.Product, // 5xx server errors - our infrastructure - 404 => ErrorCategory.User, // Not found - likely user requested invalid resource - 403 => ErrorCategory.User, // Forbidden - user environment/permission issue - 401 => ErrorCategory.User, // Unauthorized - user auth issue - 408 => ErrorCategory.User, // Request timeout - user network - 429 => ErrorCategory.User, // Too many requests - user hitting rate limits - _ => ErrorCategory.Product // Other 4xx - likely our bug (bad request format, etc.) - }; - } } diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 0e1f5f74e4ca..23ca6ea226df 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -1,11 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.ComponentModel; using System.Diagnostics; -using System.Net; -using System.Net.Sockets; -using System.Runtime.InteropServices; +using System.Text; using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; @@ -39,20 +36,14 @@ public enum ErrorCategory /// HTTP status code if applicable. /// Win32 HResult if applicable. /// Additional context (no PII - sanitized values only). -/// Method name from our code where error occurred (includes file basename and line). -/// Chain of exception types for wrapped exceptions. -/// Full stack trace (safe to include - contains no PII in NativeAOT). -/// File name and line number where the exception was originally thrown (e.g., "DotnetArchiveExtractor.cs:139"). +/// Full stack trace including inner exceptions. public sealed record ExceptionErrorInfo( string ErrorType, ErrorCategory Category = ErrorCategory.Product, int? StatusCode = null, int? HResult = null, string? Details = null, - string? SourceLocation = null, - string? ExceptionChain = null, - string? StackTrace = null, - string? ThrowSite = null); + string? StackTrace = null); /// /// Maps exceptions to error info for telemetry. @@ -85,17 +76,8 @@ public static void ApplyErrorTags(Activity? activity, ExceptionErrorInfo errorIn activity.SetTag("error.hresult", hResult); if (errorInfo is { Details: { } details }) activity.SetTag("error.details", details); - if (errorInfo is { SourceLocation: { } sourceLocation }) - activity.SetTag("error.source_location", sourceLocation); - if (errorInfo is { ExceptionChain: { } exceptionChain }) - activity.SetTag("error.exception_chain", exceptionChain); if (errorInfo is { StackTrace: { } stackTrace }) activity.SetTag("error.stack_trace", stackTrace); - if (errorInfo is { ThrowSite: { } throwSite }) - activity.SetTag("error.throw_site", throwSite); - - // NOTE: We intentionally do NOT call activity.RecordException(ex) - // because exception messages/stacks can contain PII } /// @@ -117,26 +99,21 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) return GetErrorInfo(ex.InnerException); } - // Get common enrichment data (single stack walk for all three values) - var exceptionChain = GetExceptionChain(ex); - var (sourceLocation, safeStackTrace, throwSite) = GetStackInfo(ex); + var fullStackTrace = GetFullStackTrace(ex); return ex switch { // DotnetInstallException has specific error codes - categorize by error code // Sanitize the version to prevent PII leakage (user could have typed anything) // For network-related errors, also check the inner exception for more details - DotnetInstallException installEx => GetInstallExceptionErrorInfo(installEx, sourceLocation, exceptionChain) with { StackTrace = safeStackTrace, ThrowSite = throwSite }, + DotnetInstallException installEx => GetInstallExceptionErrorInfo(installEx) with { StackTrace = fullStackTrace }, // HTTP errors: 4xx client errors are often user issues, 5xx are product/server issues HttpRequestException httpEx => new ExceptionErrorInfo( httpEx.StatusCode.HasValue ? $"Http{(int)httpEx.StatusCode}" : "HttpRequestException", - Category: GetHttpErrorCategory(httpEx.StatusCode), + Category: ErrorCategoryClassifier.ClassifyHttpError(httpEx.StatusCode), StatusCode: (int?)httpEx.StatusCode, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), // FileNotFoundException before IOException (it derives from IOException) // Could be user error (wrong path) or product error (our code referenced wrong file) @@ -146,153 +123,86 @@ public static ExceptionErrorInfo GetErrorInfo(Exception ex) Category: ErrorCategory.Product, HResult: fnfEx.HResult, Details: fnfEx.FileName is not null ? "file_specified" : null, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), // Permission denied - user environment issue (needs elevation or different permissions) UnauthorizedAccessException => new ExceptionErrorInfo( "PermissionDenied", Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), // Directory not found - could be user specified bad path DirectoryNotFoundException => new ExceptionErrorInfo( "DirectoryNotFound", Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), - IOException ioEx => MapIOException(ioEx, sourceLocation, exceptionChain, safeStackTrace, throwSite), + IOException ioEx => MapIOException(ioEx, fullStackTrace), // User cancelled the operation OperationCanceledException => new ExceptionErrorInfo( "Cancelled", Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), // Invalid argument - user provided bad input ArgumentException argEx => new ExceptionErrorInfo( "InvalidArgument", Category: ErrorCategory.User, Details: argEx.ParamName, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), // Invalid operation - usually a bug in our code InvalidOperationException => new ExceptionErrorInfo( "InvalidOperation", Category: ErrorCategory.Product, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), // Not supported - could be user trying unsupported scenario NotSupportedException => new ExceptionErrorInfo( "NotSupported", Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), // Timeout - network/environment issue outside our control TimeoutException => new ExceptionErrorInfo( "Timeout", Category: ErrorCategory.User, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite), + StackTrace: fullStackTrace), // Unknown exceptions default to product (fail-safe - we should handle known cases) _ => new ExceptionErrorInfo( ex.GetType().Name, Category: ErrorCategory.Product, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: safeStackTrace, - ThrowSite: throwSite) - }; - } - - /// - /// Gets the error category for a DotnetInstallErrorCode. - /// - private static ErrorCategory GetInstallErrorCategory(DotnetInstallErrorCode errorCode) - { - return errorCode switch - { - // User errors - bad input or environment issues - DotnetInstallErrorCode.VersionNotFound => ErrorCategory.User, // User typed invalid version - DotnetInstallErrorCode.ReleaseNotFound => ErrorCategory.User, // User requested non-existent release - DotnetInstallErrorCode.InvalidChannel => ErrorCategory.User, // User provided bad channel format - DotnetInstallErrorCode.PermissionDenied => ErrorCategory.User, // User needs to elevate/fix permissions - DotnetInstallErrorCode.DiskFull => ErrorCategory.User, // User's disk is full - DotnetInstallErrorCode.NetworkError => ErrorCategory.User, // User's network issue - - // Product errors - issues we can take action on - DotnetInstallErrorCode.ExtractionFailed => ErrorCategory.Product, // Our extraction code issue (inner IOException classified separately) - DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform => ErrorCategory.Product, // Our manifest/logic issue - DotnetInstallErrorCode.DownloadFailed => ErrorCategory.Product, // Server or download logic issue - DotnetInstallErrorCode.HashMismatch => ErrorCategory.Product, // Corrupted download or server issue - DotnetInstallErrorCode.ManifestFetchFailed => ErrorCategory.Product, // Server unreachable or CDN issue - DotnetInstallErrorCode.ManifestParseFailed => ErrorCategory.Product, // Bad manifest or our parsing bug - DotnetInstallErrorCode.ArchiveCorrupted => ErrorCategory.Product, // Bad archive from server or download - DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, // Our locking mechanism issue - DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, // File system issue with our manifest - DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, // Our manifest is corrupt - we should handle - DotnetInstallErrorCode.Unknown => ErrorCategory.Product, // Unknown = assume product issue - - _ => ErrorCategory.Product // Default to product for new codes + StackTrace: fullStackTrace) }; } /// /// Gets error info for a DotnetInstallException, enriching with inner exception details - /// for network-related errors. + /// for network-related and IO-related errors. /// private static ExceptionErrorInfo GetInstallExceptionErrorInfo( - DotnetInstallException installEx, - string? sourceLocation, - string? exceptionChain) + DotnetInstallException installEx) { var errorCode = installEx.ErrorCode; - var baseCategory = GetInstallErrorCategory(errorCode); + var baseCategory = ErrorCategoryClassifier.ClassifyInstallError(errorCode); var details = installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null; int? httpStatus = null; - // For network-related errors, check the inner exception to better categorize - // and extract additional diagnostic info - if (IsNetworkRelatedErrorCode(errorCode) && installEx.InnerException is not null) + if (ErrorCategoryClassifier.IsNetworkRelatedErrorCode(errorCode) && installEx.InnerException is not null) { - var (refinedCategory, innerHttpStatus, innerDetails) = AnalyzeNetworkException(installEx.InnerException); + var (refinedCategory, innerHttpStatus, innerDetails) = ErrorCategoryClassifier.AnalyzeNetworkException(installEx.InnerException); baseCategory = refinedCategory; httpStatus = innerHttpStatus; - // Combine details: version + inner exception info if (innerDetails is not null) { details = details is not null ? $"{details};{innerDetails}" : innerDetails; } } - // For extraction errors, check if the inner exception is an IOException and classify - // by HResult using the existing ErrorCategoryClassifier. This avoids duplicating - // HResult→error-type logic in the extraction layer. - if (IsIORelatedErrorCode(errorCode) && installEx.InnerException is IOException ioInner) + if (ErrorCategoryClassifier.IsIORelatedErrorCode(errorCode) && installEx.InnerException is IOException ioInner) { var (ioErrorType, ioCategory, ioDetails) = ErrorCategoryClassifier.ClassifyIOErrorByHResult(ioInner.HResult); baseCategory = ioCategory; @@ -307,116 +217,10 @@ private static ExceptionErrorInfo GetInstallExceptionErrorInfo( errorCode.ToString(), Category: baseCategory, StatusCode: httpStatus, - Details: details, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain); - } - - /// - /// Checks if the error code is related to network operations. - /// - private static bool IsNetworkRelatedErrorCode(DotnetInstallErrorCode errorCode) - { - return errorCode is - DotnetInstallErrorCode.ManifestFetchFailed or - DotnetInstallErrorCode.DownloadFailed or - DotnetInstallErrorCode.NetworkError; - } - - /// - /// Checks if the error code is related to IO operations where the inner exception - /// may be an IOException that should be classified by HResult. - /// - private static bool IsIORelatedErrorCode(DotnetInstallErrorCode errorCode) - { - return errorCode is - DotnetInstallErrorCode.ExtractionFailed or - DotnetInstallErrorCode.LocalManifestError; - } - - /// - /// Analyzes a network-related inner exception to determine the category and extract details. - /// - private static (ErrorCategory Category, int? HttpStatus, string? Details) AnalyzeNetworkException(Exception inner) - { - // Walk the exception chain to find HttpRequestException or SocketException - // Look for the most specific info we can find - HttpRequestException? foundHttpEx = null; - SocketException? foundSocketEx = null; - - var current = inner; - while (current is not null) - { - if (current is HttpRequestException httpEx && foundHttpEx is null) - { - foundHttpEx = httpEx; - } - - if (current is SocketException socketEx && foundSocketEx is null) - { - foundSocketEx = socketEx; - } - - current = current.InnerException; - } - - // Prefer socket-level info if available (more specific) - if (foundSocketEx is not null) - { - var socketErrorName = foundSocketEx.SocketErrorCode.ToString().ToLowerInvariant(); - return (ErrorCategory.User, null, $"socket_{socketErrorName}"); - } - - // Then HTTP-level info - if (foundHttpEx is not null) - { - var category = GetHttpErrorCategory(foundHttpEx.StatusCode); - var httpStatus = (int?)foundHttpEx.StatusCode; - - string? details = null; - if (foundHttpEx.StatusCode.HasValue) - { - details = $"http_{(int)foundHttpEx.StatusCode}"; - } - else if (foundHttpEx.HttpRequestError != HttpRequestError.Unknown) - { - // .NET 7+ has HttpRequestError enum for non-HTTP failures - details = $"request_error_{foundHttpEx.HttpRequestError.ToString().ToLowerInvariant()}"; - } - - return (category, httpStatus, details); - } - - // Couldn't determine from inner exception - use default Product category - // but mark as unknown network error - return (ErrorCategory.Product, null, "network_unknown"); - } - - /// - /// Gets the error category for an HTTP status code. - /// - private static ErrorCategory GetHttpErrorCategory(HttpStatusCode? statusCode) - { - if (!statusCode.HasValue) - { - // No status code usually means network failure - user environment - return ErrorCategory.User; - } - - var code = (int)statusCode.Value; - return code switch - { - >= 500 => ErrorCategory.Product, // 5xx server errors - our infrastructure - 404 => ErrorCategory.User, // Not found - likely user requested invalid resource - 403 => ErrorCategory.User, // Forbidden - user environment/permission issue - 401 => ErrorCategory.User, // Unauthorized - user auth issue - 408 => ErrorCategory.User, // Request timeout - user network - 429 => ErrorCategory.User, // Too many requests - user hitting rate limits - _ => ErrorCategory.Product // Other 4xx - likely our bug (bad request format, etc.) - }; + Details: details); } - private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourceLocation, string? exceptionChain, string? stackTrace, string? throwSite) + private static ExceptionErrorInfo MapIOException(IOException ioEx, string? stackTrace) { // Delegate to the single-lookup classifier to avoid duplicating HResult→category logic var (errorType, category, details) = ErrorCategoryClassifier.ClassifyIOErrorByHResult(ioEx.HResult); @@ -426,202 +230,46 @@ private static ExceptionErrorInfo MapIOException(IOException ioEx, string? sourc Category: category, HResult: ioEx.HResult, Details: details, - SourceLocation: sourceLocation, - ExceptionChain: exceptionChain, - StackTrace: stackTrace, - ThrowSite: throwSite); + StackTrace: stackTrace); } /// - /// Extracts source location, safe stack trace, and throw site from an exception - /// in a single stack walk. This replaces three separate methods that each created - /// their own StackTrace and walked the frames independently. + /// Builds a full stack trace string including inner exception types and their stack traces. + /// Exception messages are not included because they may contain user-provided input. /// - /// - /// A tuple of: - /// - SourceLocation: first frame in our code (where we called into BCL that threw) - /// - StackTrace: all frames from our namespaces joined with " -> " - /// - ThrowSite: "FileName.cs:42" from the deepest frame (or first owned frame with file info) - /// - private static (string? SourceLocation, string? StackTrace, string? ThrowSite) GetStackInfo(Exception ex) + private static string? GetFullStackTrace(Exception ex) { try { - var stackTrace = new StackTrace(ex, fNeedFileInfo: true); - var frames = stackTrace.GetFrames(); - - if (frames == null || frames.Length == 0) + var sb = new StringBuilder(); + if (ex.StackTrace is { } trace) { - return (null, null, null); + sb.Append(trace); } - string? sourceLocation = null; - string? throwSite = null; - string? bclFallbackLocation = null; - var safeFrames = new List(); - - for (int i = 0; i < frames.Length; i++) - { - var frame = frames[i]; - var methodInfo = DiagnosticMethodInfo.Create(frame); - if (methodInfo == null) continue; - - var declaringType = methodInfo.DeclaringTypeName; - if (string.IsNullOrEmpty(declaringType)) continue; - - var typeName = ExtractTypeName(declaringType); - - // Throw site: get file:line from deepest frame (i == 0), falling back to first owned frame with file info - if (i == 0) - { - var fileName = GetSafeFileName(frame); - var lineNumber = frame.GetFileLineNumber(); - if (fileName != null && lineNumber > 0) - { - throwSite = $"{fileName}:{lineNumber}"; - } - } - - // Source location fallback: first frame of any kind (BCL prefix) - if (bclFallbackLocation == null) - { - bclFallbackLocation = $"[BCL]{typeName}.{methodInfo.Name}"; - } - - if (IsOwnedNamespace(declaringType)) - { - var location = FormatFrameLocation(typeName, methodInfo.Name, frame); - safeFrames.Add(location); - - // Source location: first owned frame - sourceLocation ??= location; - - // Throw site fallback: first owned frame with file info - if (throwSite == null) - { - var fn = GetSafeFileName(frame); - var ln = frame.GetFileLineNumber(); - if (fn != null && ln > 0) - { - throwSite = $"{fn}:{ln}"; - } - } - } - } - - // If no owned frame found, use the BCL fallback for source location - sourceLocation ??= bclFallbackLocation; - - var traceString = safeFrames.Count > 0 ? string.Join(" -> ", safeFrames) : null; - return (sourceLocation, traceString, throwSite); - } - catch - { - // Never fail telemetry due to stack trace parsing - return (null, null, null); - } - } - - /// - /// Formats a stack frame location as "FileName.cs:TypeName.Method:42" or "TypeName.Method:42". - /// Includes the source file basename when available for quick identification. - /// - private static string FormatFrameLocation(string typeName, string methodName, StackFrame frame) - { - var lineNumber = frame.GetFileLineNumber(); - var fileName = GetSafeFileName(frame); - - var location = fileName != null - ? $"{fileName}:{typeName}.{methodName}" - : $"{typeName}.{methodName}"; - - if (lineNumber > 0) - { - location += $":{lineNumber}"; - } - - return location; - } - - /// - /// Extracts just the file name (no path) from a stack frame. - /// Returns null if no file info is available. - /// File names from our own build are safe (not PII) — they're paths from the build machine. - /// - private static string? GetSafeFileName(StackFrame frame) - { - var filePath = frame.GetFileName(); - if (string.IsNullOrEmpty(filePath)) - { - return null; - } - - // Strip to basename only — no directory paths - return Path.GetFileName(filePath); - } - - /// - /// Checks if a type name belongs to one of our owned namespaces. - /// - private static bool IsOwnedNamespace(string declaringType) - { - return declaringType.StartsWith("Microsoft.DotNet.Tools.Bootstrapper", StringComparison.Ordinal) || - declaringType.StartsWith("Microsoft.Dotnet.Installation", StringComparison.Ordinal); - } - - /// - /// Extracts just the type name from a fully qualified type name. - /// - private static string ExtractTypeName(string fullTypeName) - { - var typeName = fullTypeName; - var lastDot = typeName.LastIndexOf('.'); - if (lastDot >= 0) - { - typeName = typeName.Substring(lastDot + 1); - } - // Remove generic arity if present (e.g., "List`1" -> "List") - var genericMarker = typeName.IndexOf('`'); - if (genericMarker >= 0) - { - typeName = typeName.Substring(0, genericMarker); - } - return typeName; - } - - /// - /// Gets the exception type chain for wrapped exceptions. - /// Example: "HttpRequestException->SocketException" - /// - private static string? GetExceptionChain(Exception ex) - { - if (ex.InnerException == null) - { - return null; - } - - try - { - var types = new List { ex.GetType().Name }; var inner = ex.InnerException; - // Limit depth to prevent infinite loops and overly long strings const int maxDepth = 5; var depth = 0; - while (inner != null && depth < maxDepth) { - types.Add(inner.GetType().Name); + sb.AppendLine(); + sb.AppendLine($"Inner Exception: {inner.GetType().FullName}"); + if (inner.StackTrace is { } innerTrace) + { + sb.Append(innerTrace); + } inner = inner.InnerException; depth++; } - return string.Join("->", types); + return sb.Length > 0 ? sb.ToString() : null; } catch { + // Never fail telemetry due to stack trace parsing return null; } } diff --git a/src/Installer/dotnetup/Telemetry/NetworkErrorAnalyzer.cs b/src/Installer/dotnetup/Telemetry/NetworkErrorAnalyzer.cs deleted file mode 100644 index 8a2de224afea..000000000000 --- a/src/Installer/dotnetup/Telemetry/NetworkErrorAnalyzer.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Net; -using System.Net.Http; -using System.Net.Sockets; -using Microsoft.Dotnet.Installation; - -namespace Microsoft.DotNet.Tools.Bootstrapper.Telemetry; - -/// -/// Analyzes network-related exceptions to extract telemetry-safe diagnostic info -/// (HTTP status codes, socket error codes) without leaking PII. -/// -internal static class NetworkErrorAnalyzer -{ - /// - /// Checks if a is related to network operations. - /// - internal static bool IsNetworkRelatedErrorCode(DotnetInstallErrorCode errorCode) - { - return errorCode is - DotnetInstallErrorCode.ManifestFetchFailed or - DotnetInstallErrorCode.DownloadFailed or - DotnetInstallErrorCode.NetworkError; - } - - /// - /// Walks the exception chain to find HTTP and socket-level diagnostic info, - /// then determines the error category accordingly. - /// - /// - /// A tuple of (Category, HttpStatus, Details) with PII-safe diagnostic information. - /// - internal static (ErrorCategory Category, int? HttpStatus, string? Details) AnalyzeNetworkException(Exception inner) - { - // Walk the exception chain to find HttpRequestException or SocketException. - // Look for the most specific info we can find. - HttpRequestException? foundHttpEx = null; - SocketException? foundSocketEx = null; - - var current = inner; - while (current is not null) - { - if (current is HttpRequestException httpEx && foundHttpEx is null) - { - foundHttpEx = httpEx; - } - - if (current is SocketException socketEx && foundSocketEx is null) - { - foundSocketEx = socketEx; - } - - current = current.InnerException; - } - - // Prefer socket-level info if available (more specific) - if (foundSocketEx is not null) - { - var socketErrorName = foundSocketEx.SocketErrorCode.ToString().ToLowerInvariant(); - return (ErrorCategory.User, null, $"socket_{socketErrorName}"); - } - - // Then HTTP-level info - if (foundHttpEx is not null) - { - var category = ErrorCategoryClassifier.ClassifyHttpError(foundHttpEx.StatusCode); - var httpStatus = (int?)foundHttpEx.StatusCode; - - string? details = null; - if (foundHttpEx.StatusCode.HasValue) - { - details = $"http_{(int)foundHttpEx.StatusCode}"; - } - else if (foundHttpEx.HttpRequestError != HttpRequestError.Unknown) - { - // .NET 7+ has HttpRequestError enum for non-HTTP failures - details = $"request_error_{foundHttpEx.HttpRequestError.ToString().ToLowerInvariant()}"; - } - - return (category, httpStatus, details); - } - - // Couldn't determine from inner exception - use default Product category - // but mark as unknown network error - return (ErrorCategory.Product, null, "network_unknown"); - } -} diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index 0d8187de5123..bf43b352cf33 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -548,7 +548,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n throw_site = tostring(customDimensions[\"error.throw_site\"]),\n stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n exception_chain = tostring(customDimensions[\"error.exception_chain\"]),\n hresult = tostring(customDimensions[\"error.hresult\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, throw_site, error_details, stack_trace, exception_chain, hresult, os, version\n| order by timestamp desc\n| take 25", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n hresult = tostring(customDimensions[\"error.hresult\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, error_details, stack_trace, hresult, os, version\n| order by timestamp desc\n| take 25", "size": 1, "title": "Recent Product Failures (Detailed)", "queryType": 0, @@ -575,10 +575,10 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend throw_site = tostring(customDimensions[\"error.throw_site\"]),\n error_type = tostring(customDimensions[\"error.type\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| where isnotempty(throw_site)\n| summarize Count = count() by throw_site, error_type\n| order by Count desc\n| take 20\n| project ['Throw Site'] = throw_site, ['Error Type'] = error_type, Count", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend error_type = tostring(customDimensions[\"error.type\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| where isnotempty(error_type)\n| summarize Count = count() by error_type\n| order by Count desc\n| take 20\n| project ['Error Type'] = error_type, Count", "size": 1, - "title": "Product Errors by Throw Site", - "noDataMessage": "No throw site data yet. This field is populated by newer builds that emit the error.throw_site tag.", + "title": "Product Errors by Type", + "noDataMessage": "No error type data available.", "queryType": 0, "resourceType": "microsoft.insights/components", "visualization": "table" @@ -586,20 +586,6 @@ "customWidth": "50", "name": "errors-by-throw-site" }, - { - "type": 3, - "content": { - "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend exception_chain = tostring(customDimensions[\"error.exception_chain\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where isnotempty(exception_chain)\n| summarize Count = count() by exception_chain\n| order by Count desc\n| take 15\n| project ['Exception Chain'] = exception_chain, Count", - "size": 1, - "title": "Common Exception Chains (Product)", - "queryType": 0, - "resourceType": "microsoft.insights/components", - "visualization": "table" - }, - "customWidth": "50", - "name": "exception-chains" - }, { "type": 1, "content": { @@ -626,7 +612,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n throw_site = tostring(customDimensions[\"error.throw_site\"]),\n stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n exception_chain = tostring(customDimensions[\"error.exception_chain\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"user\"\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, throw_site, error_details, stack_trace, exception_chain, os, version\n| order by timestamp desc\n| take 25", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| extend command = tostring(customDimensions[\"command.name\"]),\n error_type = tostring(customDimensions[\"error.type\"]),\n error_details = tostring(customDimensions[\"error.details\"]),\n stack_trace = tostring(customDimensions[\"error.stack_trace\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"user\"\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| project timestamp, command, error_type, error_details, stack_trace, os, version\n| order by timestamp desc\n| take 25", "size": 1, "title": "Recent User Errors", "noDataMessage": "No user errors recorded yet.", diff --git a/test/dotnetup.Tests/ChannelVersionResolverTests.cs b/test/dotnetup.Tests/ChannelVersionResolverTests.cs index a0d55427eaa1..e8c0f62ecadf 100644 --- a/test/dotnetup.Tests/ChannelVersionResolverTests.cs +++ b/test/dotnetup.Tests/ChannelVersionResolverTests.cs @@ -112,6 +112,7 @@ public void GetLatestVersionForChannel_Preview_ReturnsLatestPreviewVersion() [InlineData("10", true)] [InlineData("99", true)] // Max reasonable major [InlineData("99.0.100", true)] + [InlineData("10.0.100-preview.1.32640", true)] // Full prerelease version public void IsValidChannelFormat_ValidInputs_ReturnsTrue(string channel, bool expected) { Assert.Equal(expected, ChannelVersionResolver.IsValidChannelFormat(channel)); @@ -127,6 +128,8 @@ public void IsValidChannelFormat_ValidInputs_ReturnsTrue(string channel, bool ex [InlineData("invalid", false)] [InlineData("-1", false)] // Negative [InlineData("9.-1.100", false)] // Negative minor + [InlineData("10.0.1xxx", false)] // Invalid wildcard + [InlineData("10.0.1xx-preview.1", false)] // Wildcards with prerelease not supported public void IsValidChannelFormat_InvalidInputs_ReturnsFalse(string channel, bool expected) { Assert.Equal(expected, ChannelVersionResolver.IsValidChannelFormat(channel)); diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index b5f1da2ba333..6b49e1fa941e 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -75,7 +75,7 @@ public void GetErrorInfo_HttpRequestException_WithStatusCode_MapsCorrectly() } [Fact] - public void GetErrorInfo_WrappedException_IncludesChain() + public void GetErrorInfo_WrappedException_IncludesInnerExceptionInStackTrace() { var innerInner = new SocketException(10054); // Connection reset var inner = new IOException("Network error", innerInner); @@ -84,7 +84,10 @@ public void GetErrorInfo_WrappedException_IncludesChain() var info = ErrorCodeMapper.GetErrorInfo(outer); Assert.Equal("HttpRequestException", info.ErrorType); - Assert.Equal("HttpRequestException->IOException->SocketException", info.ExceptionChain); + // Inner exception types should be included in the stack trace + Assert.NotNull(info.StackTrace); + Assert.Contains("System.IO.IOException", info.StackTrace); + Assert.Contains("System.Net.Sockets.SocketException", info.StackTrace); } [Fact] @@ -119,38 +122,20 @@ public void GetErrorInfo_InvalidOperationException_MapsCorrectly() } [Fact] - public void GetErrorInfo_ExceptionFromOurCode_IncludesSourceLocation() + public void GetErrorInfo_ThrownException_HasStackTrace() { // Throw from a method to get a real stack trace var ex = ThrowTestException(); var info = ErrorCodeMapper.GetErrorInfo(ex); - // Source location is only populated for our owned assemblies (dotnetup, Microsoft.Dotnet.Installation) - // In tests, we won't have those on the stack, so source location will be null - // The important thing is that the method doesn't throw Assert.Equal("InvalidOperation", info.ErrorType); + Assert.NotNull(info.StackTrace); + Assert.Contains("ThrowTestException", info.StackTrace); } [Fact] - public void GetErrorInfo_SourceLocation_FiltersToOwnedNamespaces() - { - // Verify that source location filtering works by namespace prefix - // We must throw and catch to get a stack trace - exceptions created with 'new' have no trace - var ex = ThrowTestException(); - - var info = ErrorCodeMapper.GetErrorInfo(ex); - - // Source location should be populated since test assembly is in an owned namespace - // (Microsoft.DotNet.Tools.Bootstrapper.Tests starts with Microsoft.DotNet.Tools.Bootstrapper) - Assert.NotNull(info.SourceLocation); - // The format is "TypeName.MethodName" - no [BCL] prefix since we found owned code - Assert.DoesNotContain("[BCL]", info.SourceLocation); - Assert.Contains("ThrowTestException", info.SourceLocation); - } - - [Fact] - public void GetErrorInfo_AllFieldsPopulated_ForIOExceptionWithChain() + public void GetErrorInfo_AllFieldsPopulated_ForIOExceptionWithInnerException() { // Create a realistic exception scenario - IOException with inner exception var inner = new UnauthorizedAccessException("Access denied"); @@ -158,9 +143,10 @@ public void GetErrorInfo_AllFieldsPopulated_ForIOExceptionWithChain() var info = ErrorCodeMapper.GetErrorInfo(outer); - // Verify exception chain is populated + // Verify inner exception type is included in stack trace Assert.Equal("IOException", info.ErrorType); - Assert.Equal("IOException->UnauthorizedAccessException", info.ExceptionChain); + Assert.NotNull(info.StackTrace); + Assert.Contains("System.UnauthorizedAccessException", info.StackTrace); } [Fact] @@ -190,9 +176,9 @@ private static Exception ThrowTestException() } [Fact] - public void GetErrorInfo_LongExceptionChain_LimitsDepth() + public void GetErrorInfo_LongExceptionChain_IncludesInnerExceptionsInStackTrace() { - // Create a chain of typed exceptions (not plain Exception which gets unwrapped) + // Create a chain of typed exceptions Exception ex = new InvalidOperationException("Root"); for (int i = 0; i < 10; i++) { @@ -201,11 +187,10 @@ public void GetErrorInfo_LongExceptionChain_LimitsDepth() var info = ErrorCodeMapper.GetErrorInfo(ex); - // Should have an exception chain since we're using IOException wrappers - Assert.NotNull(info.ExceptionChain); - var parts = info.ExceptionChain!.Split("->"); - // Chain is limited to maxDepth (5) + 1 for the outer exception = 6 - Assert.True(parts.Length <= 6, $"Chain too long: {info.ExceptionChain}"); + // Stack trace should include inner exception types + Assert.NotNull(info.StackTrace); + Assert.Contains("System.IO.IOException", info.StackTrace); + Assert.Contains("System.InvalidOperationException", info.StackTrace); } [Fact] diff --git a/test/dotnetup.Tests/InfoCommandTests.cs b/test/dotnetup.Tests/InfoCommandTests.cs index 05d339847506..8ad75da39b7b 100644 --- a/test/dotnetup.Tests/InfoCommandTests.cs +++ b/test/dotnetup.Tests/InfoCommandTests.cs @@ -9,25 +9,6 @@ namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public class InfoCommandTests { - /// - /// Creates an InfoCommand instance with the given parameters. - /// - private static InfoCommand CreateInfoCommand(OutputFormat format, bool noList, TextWriter output) - { - // Create a minimal ParseResult for the command - var parseResult = Parser.Parse(new[] { "--info" }); - return new InfoCommand(parseResult, format, noList, output); - } - - /// - /// Executes the InfoCommand and returns the exit code. - /// - private static int ExecuteInfoCommand(OutputFormat format, bool noList, TextWriter output) - { - var command = CreateInfoCommand(format, noList, output); - return command.Execute(); - } - [Fact] public void Parser_ShouldParseInfoCommand() { diff --git a/test/dotnetup.Tests/InstallTelemetryTests.cs b/test/dotnetup.Tests/InstallTelemetryTests.cs index 218318319421..a859f0ec20f3 100644 --- a/test/dotnetup.Tests/InstallTelemetryTests.cs +++ b/test/dotnetup.Tests/InstallTelemetryTests.cs @@ -266,8 +266,7 @@ public void ApplyErrorTags_SetsAllRequiredTags() HResult: unchecked((int)0x80070070), StatusCode: null, Details: "ERROR_DISK_FULL", - StackTrace: "InstallExecutor.cs:42", - ExceptionChain: "IOException", + StackTrace: "at SomeMethod() in InstallExecutor.cs:line 42", Category: ErrorCategory.User); ErrorCodeMapper.ApplyErrorTags(activity, errorInfo); @@ -278,8 +277,7 @@ public void ApplyErrorTags_SetsAllRequiredTags() Assert.Equal("user", a.GetTagItem("error.category")); Assert.Equal(unchecked((int)0x80070070), a.GetTagItem("error.hresult")); Assert.Equal("ERROR_DISK_FULL", a.GetTagItem("error.details")); - Assert.Equal("InstallExecutor.cs:42", a.GetTagItem("error.stack_trace")); - Assert.Equal("IOException", a.GetTagItem("error.exception_chain")); + Assert.Equal("at SomeMethod() in InstallExecutor.cs:line 42", a.GetTagItem("error.stack_trace")); } [Fact] @@ -298,7 +296,6 @@ public void ApplyErrorTags_WithErrorCode_SetsErrorCodeTag() StatusCode: 404, Details: null, StackTrace: null, - ExceptionChain: "HttpRequestException", Category: ErrorCategory.Product); ErrorCodeMapper.ApplyErrorTags(activity, errorInfo, errorCode: "Http404"); @@ -322,7 +319,6 @@ public void ApplyErrorTags_WithNullActivity_DoesNotThrow() StatusCode: null, Details: null, StackTrace: null, - ExceptionChain: null, Category: ErrorCategory.Product); var ex = Record.Exception(() => ErrorCodeMapper.ApplyErrorTags(null, errorInfo)); @@ -346,7 +342,6 @@ public void ApplyErrorTags_NullOptionalFields_DoesNotSetOptionalTags() StatusCode: null, Details: null, StackTrace: null, - ExceptionChain: null, Category: ErrorCategory.Product); ErrorCodeMapper.ApplyErrorTags(activity, errorInfo); @@ -360,7 +355,6 @@ public void ApplyErrorTags_NullOptionalFields_DoesNotSetOptionalTags() Assert.Null(a.GetTagItem("error.http_status")); Assert.Null(a.GetTagItem("error.details")); Assert.Null(a.GetTagItem("error.stack_trace")); - Assert.Null(a.GetTagItem("error.exception_chain")); Assert.Null(a.GetTagItem("error.code")); } @@ -380,7 +374,6 @@ public void ApplyErrorTags_SetsActivityStatusToError() StatusCode: null, Details: null, StackTrace: null, - ExceptionChain: null, Category: ErrorCategory.Product); ErrorCodeMapper.ApplyErrorTags(activity, errorInfo); diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 09f16bfd61b7..6a5e324c2d64 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -320,47 +320,6 @@ public void RecordException_WithNullActivity_DoesNotThrow() } } -[Collection("ActivitySourceTests")] -public class LibraryActivityTagTests -{ - [Fact] - public void NonUpdatingProgressTarget_SetsCallerTagOnActivity() - { - var capturedActivities = new List(); - - using var listener = new System.Diagnostics.ActivityListener - { - ShouldListenTo = source => source.Name == "Microsoft.Dotnet.Installation", - Sample = (ref System.Diagnostics.ActivityCreationOptions _) => - System.Diagnostics.ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => capturedActivities.Add(activity) - }; - System.Diagnostics.ActivitySource.AddActivityListener(listener); - - // Capture console output to avoid test noise - var originalOut = Console.Out; - Console.SetOut(TextWriter.Null); - try - { - // Use the progress target to create an activity - var progressTarget = new NonUpdatingProgressTarget(); - using var reporter = progressTarget.CreateProgressReporter(); - var task = reporter.AddTask("test-activity", "Test Description", 100); - task.Value = 100; - // Disposing the reporter will stop/dispose the activities - } - finally - { - Console.SetOut(originalOut); - } - - // Verify the activity was captured and has the caller tag - Assert.Single(capturedActivities); - var activity = capturedActivities[0]; - Assert.Equal("dotnetup", activity.GetTagItem("caller")?.ToString()); - } -} - public class FirstRunNoticeTests : IDisposable { private const string NoLogoEnvVar = "DOTNET_NOLOGO"; @@ -467,32 +426,4 @@ public void ActivityListener_CanCaptureActivities_FromInstallationActivitySource Assert.Equal("test-activity", capturedActivities[0].DisplayName); Assert.Contains(capturedActivities[0].Tags, t => t.Key == "test.key" && t.Value == "test-value"); } - - [Fact] - public void NonUpdatingProgressTarget_SetsCallerTag_ToDotnetup() - { - // Arrange - set up listener to capture activities - var capturedActivities = new List(); - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == InstallationActivitySourceName, - Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = activity => capturedActivities.Add(activity) - }; - ActivitySource.AddActivityListener(listener); - - // Act - use NonUpdatingProgressTarget which sets caller=dotnetup - var progressTarget = new NonUpdatingProgressTarget(); - using (var reporter = progressTarget.CreateProgressReporter()) - { - var task = reporter.AddTask("download", "Test download task", 100); - task.Value = 100; - task.Complete(); - } - - // Assert - verify caller tag is set to dotnetup - Assert.Single(capturedActivities); - var callerTag = capturedActivities[0].Tags.FirstOrDefault(t => t.Key == "caller"); - Assert.Equal("dotnetup", callerTag.Value); - } } From dba6bffe57cdb75db6dc1bbf6f1a135a0b29ef7c Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 15:48:43 -0800 Subject: [PATCH 58/59] bug fix for preview version with extra '.' --- .../Internal/ChannelVersionResolver.cs | 6 +++++- src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs index aeee088633b0..c16e4082f6ee 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs @@ -107,8 +107,12 @@ public static bool IsValidChannelFormat(string channel) return true; } + // Strip prerelease suffix (e.g., "10.0.100-preview.1.32640" -> "10.0.100") + var dashIndex = channel.IndexOf('-'); + var versionPart = dashIndex >= 0 ? channel.Substring(0, dashIndex) : channel; + // Try to parse as a version-like string - var parts = channel.Split('.'); + var parts = versionPart.Split('.'); if (parts.Length == 0 || parts.Length > 4) { return false; diff --git a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs index 23ca6ea226df..acd3df73cf95 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -251,7 +251,7 @@ private static ExceptionErrorInfo MapIOException(IOException ioEx, string? stack var inner = ex.InnerException; // Limit depth to prevent infinite loops and overly long strings - const int maxDepth = 5; + const int maxDepth = 10; var depth = 0; while (inner != null && depth < maxDepth) { From db052de29c5589e34ede7cf15d3308fad11bca08 Mon Sep 17 00:00:00 2001 From: Noah Gilson Date: Mon, 23 Feb 2026 15:59:14 -0800 Subject: [PATCH 59/59] Prerelease version validation fix --- .../Internal/ChannelVersionResolver.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs index c16e4082f6ee..27ab8125c9db 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs @@ -107,9 +107,10 @@ public static bool IsValidChannelFormat(string channel) return true; } - // Strip prerelease suffix (e.g., "10.0.100-preview.1.32640" -> "10.0.100") + // Check for prerelease suffix (e.g., "10.0.100-preview.1.32640") var dashIndex = channel.IndexOf('-'); - var versionPart = dashIndex >= 0 ? channel.Substring(0, dashIndex) : channel; + var hasPrerelease = dashIndex >= 0; + var versionPart = hasPrerelease ? channel.Substring(0, dashIndex) : channel; // Try to parse as a version-like string var parts = versionPart.Split('.'); @@ -142,10 +143,16 @@ public static bool IsValidChannelFormat(string channel) } // Allow either: - // - a fully specified numeric patch (e.g., "103"), or - // - a feature band pattern with a numeric prefix and "xx" suffix (e.g., "1xx", "101xx"). + // - a fully specified numeric patch (e.g., "103"), optionally with a prerelease suffix, or + // - a feature band pattern with a numeric prefix and "xx" suffix (e.g., "1xx", "101xx"), + // but NOT with a prerelease suffix (wildcards with prerelease not supported). if (patch.EndsWith("xx", StringComparison.OrdinalIgnoreCase)) { + if (hasPrerelease) + { + return false; + } + var prefix = patch.Substring(0, patch.Length - 2); if (prefix.Length == 0 || !int.TryParse(prefix, out _)) {