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. 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/Microsoft.Dotnet.Installation/DotnetInstallException.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs new file mode 100644 index 000000000000..f2944d5b466c --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs @@ -0,0 +1,110 @@ +// 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. + NoMatchingReleaseFileForPlatform, + + /// 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, + + /// 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, + + /// Failed to read/write the dotnetup installation manifest. + LocalManifestError, + + /// The dotnetup installation manifest is corrupted. + LocalManifestCorrupted, +} + +/// +/// 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/IProgressTarget.cs b/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs index eb8779adc702..32f167387691 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/IProgressTarget.cs @@ -1,20 +1,16 @@ // 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); } public interface IProgressTask @@ -22,24 +18,21 @@ public interface IProgressTask string Description { get; set; } double Value { get; set; } double MaxValue { get; set; } - - } 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); } - class NullProgressTask : IProgressTask + + private sealed class NullProgressTask : IProgressTask { public NullProgressTask(string description) { diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs index cd42f4981239..27ab8125c9db 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs @@ -10,6 +10,37 @@ 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]; + + /// + /// 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() @@ -25,7 +56,7 @@ public ChannelVersionResolver(ReleaseManifest releaseManifest) public IEnumerable GetSupportedChannels(bool includeFeatureBands = true) { var productIndex = _releaseManifest.GetReleasesIndex(); - return ["latest", "preview", "lts", "sts", + return [..KnownChannelKeywords, ..productIndex .Where(p => p.IsSupported) .OrderByDescending(p => p.LatestReleaseVersion) @@ -57,6 +88,89 @@ static IEnumerable GetChannelsForProduct(Product product, bool includeFe 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; + } + + // Check for prerelease suffix (e.g., "10.0.100-preview.1.32640") + var dashIndex = channel.IndexOf('-'); + var hasPrerelease = dashIndex >= 0; + var versionPart = hasPrerelease ? channel.Substring(0, dashIndex) : channel; + + // Try to parse as a version-like string + var parts = versionPart.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]; + if (string.IsNullOrEmpty(patch)) + { + return false; + } + + // Allow either: + // - 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 _)) + { + return false; + } + } + else + { + if (!int.TryParse(patch, out var numericPatch) || numericPatch < 0) + { + return false; + } + } + } + + return true; + } + /// /// Parses a version channel string into its components. /// @@ -97,18 +211,18 @@ static IEnumerable GetChannelsForProduct(Product product, bool includeFe /// 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/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs index b6346b94be3b..bf6bd9337ef7 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveDownloader.cs @@ -1,19 +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; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; +using System.Diagnostics; using System.Reflection; using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Deployment.DotNet.Releases; -using Microsoft.Dotnet.Installation; namespace Microsoft.Dotnet.Installation.Internal; @@ -200,25 +191,49 @@ 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) + public void DownloadArchiveWithVerification( + DotnetInstallRequest installRequest, + ReleaseVersion resolvedVersion, + string destinationPath, + IProgress? progress = null) { var targetFile = _releaseManifest.FindReleaseFile(installRequest, resolvedVersion); - string? downloadUrl = targetFile?.Address.ToString(); - string? expectedHash = targetFile?.Hash.ToString(); + + if (targetFile == null) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.NoMatchingReleaseFileForPlatform, + $"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()); } + Activity.Current?.SetTag("download.url_domain", UrlSanitizer.SanitizeDomain(downloadUrl)); + // Check the cache first string? cachedFilePath = _downloadCache.GetCachedFilePath(downloadUrl); if (cachedFilePath != null) @@ -233,6 +248,10 @@ public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest, // Report 100% progress immediately since we're using cache progress?.Report(new DownloadProgress(100, 100)); + + var cachedFileInfo = new FileInfo(cachedFilePath); + Activity.Current?.SetTag("download.bytes", cachedFileInfo.Length); + Activity.Current?.SetTag("download.from_cache", true); return; } catch @@ -247,6 +266,10 @@ public void DownloadArchiveWithVerification(DotnetInstallRequest installRequest, // Verify the downloaded file VerifyFileHash(destinationPath, expectedHash); + var fileInfo = new FileInfo(destinationPath); + Activity.Current?.SetTag("download.bytes", fileInfo.Length); + Activity.Current?.SetTag("download.from_cache", false); + // Add the verified file to the cache try { @@ -311,7 +334,9 @@ public static void VerifyFileHash(string filePath, string expectedHash) return; } - throw new Exception($"File hash mismatch. Expected: {expectedHash}, Actual: {actualHash}"); + throw new DotnetInstallException( + DotnetInstallErrorCode.HashMismatch, + $"File hash mismatch. Expected: {expectedHash}, Actual: {actualHash}"); } public void Dispose() diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index 0cc5e3d7aa15..77c4d8dd7f3b 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Formats.Tar; using System.IO; using System.IO.Compression; using System.Linq; +using System.Net.Http; using System.Runtime.InteropServices; using Microsoft.Deployment.DotNet.Releases; @@ -63,7 +65,8 @@ public DotnetArchiveExtractor( public void Prepare() { - using var activity = InstallationActivitySource.ActivitySource.StartActivity("DotnetInstaller.Prepare"); + using var activity = InstallationActivitySource.ActivitySource.StartActivity("download"); + activity?.SetTag("download.version", _resolvedVersion.ToString()); var archiveName = $"dotnet-{Guid.NewGuid()}"; _archivePath = Path.Combine(scratchDownloadDirectory, archiveName + DotnetupUtilities.GetArchiveFileExtensionForPlatform()); @@ -76,23 +79,92 @@ public void Prepare() { _archiveDownloader.DownloadArchiveWithVerification(_request, _resolvedVersion, _archivePath, reporter); } + catch (DotnetInstallException) + { + throw; + } + catch (HttpRequestException 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) { - 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()); } downloadTask.Value = 100; } + public void Commit() { - using var activity = InstallationActivitySource.ActivitySource.StartActivity("DotnetInstaller.Commit"); + using var activity = InstallationActivitySource.ActivitySource.StartActivity("extract"); + activity?.SetTag("download.version", _resolvedVersion.ToString()); string componentDescription = _request.Component.GetDisplayName(); var installTask = ProgressReporter.AddTask($"Installing {componentDescription} {_resolvedVersion}", maxValue: 100); - // Extract archive directly to target directory with special handling for muxer - ExtractArchiveDirectlyToTarget(_archivePath!, _request.InstallRoot.Path!, installTask); - installTask.Value = installTask.MaxValue; + try + { + // Extract archive directly to target directory with special handling for muxer + ExtractArchiveDirectlyToTarget(_archivePath!, _request.InstallRoot.Path!, installTask); + installTask.Value = installTask.MaxValue; + } + catch (DotnetInstallException) + { + throw; + } + catch (InvalidDataException ex) + { + // Archive is corrupted (invalid zip/tar format) + throw new DotnetInstallException( + DotnetInstallErrorCode.ArchiveCorrupted, + $"Archive is corrupted or truncated for version {_resolvedVersion}: {ex.Message}", + ex, + 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}", + ex, + version: _resolvedVersion.ToString(), + component: _request.Component.ToString()); + } } /// @@ -274,6 +346,7 @@ private void ExtractZipArchive(string archivePath, string targetDir, IProgressTa installTask?.Value += 1; } + } 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..cf44336939de 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/InstallationActivitySource.cs @@ -6,9 +6,51 @@ 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. +/// +/// +/// +/// 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: +/// +/// +/// downloadSDK/runtime archive download. Tags: download.version, download.url, download.bytes, download.from_cache +/// extractArchive extraction. Tags: download.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 { - 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/Microsoft.Dotnet.Installation/Internal/MuxerHandler.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/MuxerHandler.cs index 64a55fc9f545..5b633b4da099 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/MuxerHandler.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/MuxerHandler.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics; using System.IO; using Microsoft.Deployment.DotNet.Releases; @@ -96,6 +97,7 @@ public void FinalizeAfterExtraction() // If no muxer was extracted (e.g., WindowsDesktop), nothing to do if (!MuxerWasExtracted) { + Activity.Current?.SetTag("muxer.action", "skipped_not_in_archive"); return; } @@ -103,6 +105,7 @@ public void FinalizeAfterExtraction() // to its final location - nothing more to do. if (!_hadExistingMuxer) { + Activity.Current?.SetTag("muxer.action", "new_install"); return; } @@ -144,6 +147,9 @@ public void FinalizeAfterExtraction() if (!shouldUpdateMuxer) { TryDeleteTempMuxer(); + Activity.Current?.SetTag("muxer.action", "kept_existing"); + Activity.Current?.SetTag("muxer.existing_version", VersionSanitizer.Sanitize(_preExtractionHighestRuntimeVersion?.ToString())); + Activity.Current?.SetTag("muxer.archive_version", VersionSanitizer.Sanitize(postExtractionHighestRuntimeVersion?.ToString())); return; } @@ -170,6 +176,8 @@ public void FinalizeAfterExtraction() Console.Error.WriteLine( $"Warning: Could not update dotnet executable at '{_muxerTargetPath}' - {reason}. " + $"The existing muxer will be retained. This may cause issues if the new runtime requires a newer muxer."); + Activity.Current?.SetTag("muxer.action", "blocked"); + Activity.Current?.SetTag("muxer.blocked_reason", reason); return; } } @@ -179,6 +187,10 @@ public void FinalizeAfterExtraction() // Move the new muxer into place File.Move(_tempMuxerPath, _muxerTargetPath); + Activity.Current?.SetTag("muxer.action", "updated"); + Activity.Current?.SetTag("muxer.previous_version", VersionSanitizer.Sanitize(_preExtractionHighestRuntimeVersion?.ToString())); + Activity.Current?.SetTag("muxer.new_version", VersionSanitizer.Sanitize(postExtractionHighestRuntimeVersion?.ToString())); + // Clean up the backup if (_movedExistingMuxer && File.Exists(_existingMuxerBackupPath)) { diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ReleaseManifest.cs index e4560c89da21..d813bbadef1f 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; @@ -29,13 +30,50 @@ 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 (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 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()); } } diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs index a076efff7182..9f71d50a9a54 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ScopedMutex.cs @@ -13,6 +13,16 @@ 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; + + /// + /// 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 @@ -23,11 +33,27 @@ public ScopedMutex(string name) } _mutex = new Mutex(false, mutexName); - _hasHandle = _mutex.WaitOne(TimeSpan.FromSeconds(300), 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; } + // 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 +78,4 @@ public void Dispose() } _mutex.Dispose(); } -} +} \ No newline at end of file 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/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs new file mode 100644 index 000000000000..7cd92c58b1a6 --- /dev/null +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/VersionSanitizer.cs @@ -0,0 +1,122 @@ +// 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.Deployment.DotNet.Releases; + +namespace Microsoft.Dotnet.Installation.Internal; + +/// +/// 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 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}xx|\d{2}x)$")] + private static partial Regex WildcardPatternRegex(); + + /// + /// 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 using ReleaseVersion parsing. + /// For wildcard patterns (1xx, 10x), substitutes wildcards with '00' and validates. + /// + private static bool IsValidVersionPattern(string version) + { + // First check for wildcard patterns (e.g., 9.0.1xx, 10.0.20x) + if (WildcardPatternRegex().IsMatch(version)) + { + // 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); + + return ReleaseVersion.TryParse(normalized, out _); + } + + if (ReleaseVersion.TryParse(version, out ReleaseVersion releaseVersion)) + { + if (releaseVersion.Prerelease is null) + { + return true; + } + + // Validate prerelease token: must start with a known token + var dotIndex = releaseVersion.Prerelease.IndexOf('.'); + var token = dotIndex < 0 ? releaseVersion.Prerelease : releaseVersion.Prerelease[..dotIndex]; + + return KnownPrereleaseTokens.Contains(token, StringComparer.OrdinalIgnoreCase); + } + + + // 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 true; + } + + return false; + } + + /// + /// 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); + } +} diff --git a/src/Installer/dotnetup/.vscode/launch.json b/src/Installer/dotnetup/.vscode/launch.json index 5e1eb1d116c4..401b41a481f4 100644 --- a/src/Installer/dotnetup/.vscode/launch.json +++ b/src/Installer/dotnetup/.vscode/launch.json @@ -1,30 +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 - }, - { - "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/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index e1b27c8efc9f..3cd4c0ddfbf2 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -1,29 +1,122 @@ // 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.Text; +using System.Diagnostics; +using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; +using Spectre.Console; 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; + private int _exitCode; protected CommandBase(ParseResult parseResult) { _parseResult = parseResult; - //ShowHelpOrErrorIfAppropriate(parseResult); } - //protected CommandBase() { } + /// + /// 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); + _exitCode = 1; + + try + { + _exitCode = ExecuteCore(); + return _exitCode; + } + catch (DotnetInstallException ex) + { + // Known installation errors - print a clean user-friendly message + DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); + AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]"); + return 1; + } + finally + { + _commandActivity?.SetTag(TelemetryTagNames.ExitCode, _exitCode); + _commandActivity?.SetStatus(_exitCode == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); + _commandActivity?.Dispose(); + } + } + + /// + /// Implement this method to provide the command's core logic. + /// + /// The exit code of the command. + protected abstract int ExecuteCore(); - //protected virtual void ShowHelpOrErrorIfAppropriate(ParseResult parseResult) - //{ - // parseResult.ShowHelpOrErrorIfAppropriate(); - //} + /// + /// Gets the command name for telemetry purposes (e.g., "sdk/install", "list"). + /// + protected abstract string GetCommandName(); - public abstract int Execute(); + /// + /// 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); + } + + /// + /// 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. + /// Error category: "user" for input/environment issues, "product" for bugs (default). + protected void RecordFailure(string reason, string? message = null, string category = "product") + { + _commandActivity?.SetTag(TelemetryTagNames.ErrorType, reason); + _commandActivity?.SetTag(TelemetryTagNames.ErrorCategory, category); + if (message != null) + { + _commandActivity?.SetTag(TelemetryTagNames.ErrorMessage, 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(TelemetryTagNames.DotnetRequestedVersion, sanitized); + } + + /// + /// 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(TelemetryTagNames.DotnetRequestSource, source); + if (requestedValue != null) + { + var sanitized = VersionSanitizer.Sanitize(requestedValue); + _commandActivity?.SetTag(TelemetryTagNames.DotnetRequested, sanitized); + } + } } diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index e9c5622d01c5..a6b5bc7f6852 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 { @@ -59,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; } } @@ -100,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/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/Info/InfoCommand.cs b/src/Installer/dotnetup/Commands/Info/InfoCommand.cs index e3af12c7e000..73ba8b0af906 100644 --- a/src/Installer/dotnetup/Commands/Info/InfoCommand.cs +++ b/src/Installer/dotnetup/Commands/Info/InfoCommand.cs @@ -1,37 +1,72 @@ // 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.CommandLine; 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; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Info; -internal static class InfoCommand +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) { - output ??= Console.Out; + _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) + { + _format = format; + _noList = noList; + _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"; + + protected override int ExecuteCore() + { var info = GetDotnetupInfo(); List? installations = null; - if (!noList) + if (!_noList) { // --info verifies by default installations = InstallationLister.GetInstallations(verify: true); } - if (format == OutputFormat.Json) + if (_format == OutputFormat.Json) { - PrintJsonInfo(output, info, installations); + PrintJsonInfo(_output, info, installations); } else { - PrintHumanReadableInfo(output, info, installations); + PrintHumanReadableInfo(_output, info, installations); } return 0; @@ -39,40 +74,15 @@ public static int Execute(OutputFormat format, bool noList = false, TextWriter? 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/Commands/Info/InfoCommandParser.cs b/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs index 2bd541244656..0b206606ba08 100644 --- a/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Info/InfoCommandParser.cs @@ -31,9 +31,8 @@ private static Command ConstructCommand() command.SetAction(parseResult => { - var format = parseResult.GetValue(FormatOption); - var noList = parseResult.GetValue(NoListOption); - return Info.InfoCommand.Execute(format, noList); + var infoCommand = new Info.InfoCommand(parseResult); + return infoCommand.Execute(); }); return command; diff --git a/src/Installer/dotnetup/Commands/List/ListCommand.cs b/src/Installer/dotnetup/Commands/List/ListCommand.cs index a4ca2a92436f..ef6c435c798f 100644 --- a/src/Installer/dotnetup/Commands/List/ListCommand.cs +++ b/src/Installer/dotnetup/Commands/List/ListCommand.cs @@ -21,7 +21,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/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs index caf2848ac1cb..3b3210e44a4b 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -18,7 +18,9 @@ public PrintEnvScriptCommand(ParseResult result, IDotnetInstallManager? dotnetIn _dotnetInstallPath = result.GetValue(PrintEnvScriptCommandParser.DotnetInstallPathOption); } - public override int Execute() + 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 fce7662fbe93..11bd8c75056d 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs @@ -31,7 +31,9 @@ internal class RuntimeInstallCommand(ParseResult result) : CommandBase(result) ["windowsdesktop"] = InstallComponent.WindowsDesktop, }; - public override int Execute() + protected override string GetCommandName() => "runtime/install"; + + protected override int ExecuteCore() { // Parse the component spec to determine runtime type and version var (component, versionOrChannel, errorMessage) = ParseComponentSpec(_componentSpec); @@ -39,6 +41,7 @@ public override int Execute() if (errorMessage != null) { Console.Error.WriteLine(errorMessage); + RecordFailure("invalid_component_spec", category: "user"); return 1; } @@ -47,6 +50,7 @@ public override int Execute() { 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; } @@ -55,6 +59,7 @@ public override int Execute() { 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/Sdk/Install/SdkInstallCommand.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs index 657c44cc4af3..66b92377467f 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommand.cs @@ -3,7 +3,6 @@ using System.CommandLine; using Microsoft.Dotnet.Installation.Internal; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install; @@ -22,7 +21,9 @@ internal class SdkInstallCommand(ParseResult result) : CommandBase(result) private readonly IDotnetInstallManager _dotnetInstaller = new DotnetInstallManager(); private readonly ChannelVersionResolver _channelVersionResolver = new ChannelVersionResolver(); - public override int Execute() + protected override string GetCommandName() => "sdk/install"; + + protected override int ExecuteCore() { var workflow = new InstallWorkflow(_dotnetInstaller, _channelVersionResolver); @@ -45,8 +46,6 @@ public override int Execute() string? ResolveChannelFromGlobalJson(string globalJsonPath) { - //return null; - //return "9.0"; return Environment.GetEnvironmentVariable("DOTNET_TESTHOOK_GLOBALJSON_SDK_CHANNEL"); } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index 5e5b137b423e..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); + public record InstallResult(DotnetInstall? Install, bool WasAlreadyInstalled = false) + { + public bool Success => Install is not null; + } /// /// Result of creating and resolving an install request. @@ -73,15 +77,23 @@ public static InstallResult ExecuteInstall( { SpectreAnsiConsole.MarkupLineInterpolated($"Installing {componentDescription} [blue]{resolvedVersion}[/] to [blue]{installRequest.InstallRoot.Path}[/]..."); - var install = InstallerOrchestratorSingleton.Instance.Install(installRequest, noProgress); - if (install == null) + var installResult = InstallerOrchestratorSingleton.Instance.Install(installRequest, noProgress); + if (installResult.Install == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install {componentDescription} {resolvedVersion}[/]"); - return new InstallResult(false, null); + return new InstallResult(null); + } + + 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}[/]"); } - SpectreAnsiConsole.MarkupLine($"[green]Installed {componentDescription} {install.Version}, available via {install.InstallRoot}[/]"); - return new InstallResult(true, install); + return new InstallResult(installResult.Install, installResult.WasAlreadyInstalled); } /// @@ -118,15 +130,15 @@ public static bool ExecuteAdditionalInstalls( RequireMuxerUpdate = requireMuxerUpdate }); - var additionalInstall = InstallerOrchestratorSingleton.Instance.Install(additionalRequest, noProgress); - if (additionalInstall == null) + var additionalResult = InstallerOrchestratorSingleton.Instance.Install(additionalRequest, noProgress); + if (additionalResult.Install == null) { SpectreAnsiConsole.MarkupLine($"[red]Failed to install additional {componentDescription} {additionalVersion}[/]"); allSucceeded = false; } else { - SpectreAnsiConsole.MarkupLine($"[green]Installed additional {componentDescription} {additionalInstall.Version}, available via {additionalInstall.InstallRoot}[/]"); + SpectreAnsiConsole.MarkupLine($"[green]Installed additional {componentDescription} {additionalResult.Install.Version}, available via {additionalResult.Install.InstallRoot}[/]"); } } @@ -157,4 +169,113 @@ 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. + /// + /// 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, PathSource? 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); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + 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 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 + { + 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"; + } + } + + // 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 == PathSource.GlobalJson) + { + return "global_json"; + } + + return "other"; + } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs index 1b5dcde9bb58..eb60d767797d 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallPathResolver.cs @@ -21,11 +21,16 @@ public InstallPathResolver(IDotnetInstallManager dotnetInstaller) } /// - /// Result of install path resolution containing the resolved path and any path from global.json. + /// Result of install path resolution containing the resolved path, any path from global.json, + /// and how the path was determined (for telemetry). /// + /// The final resolved install path. + /// The install path from global.json, if any. + /// How the path was determined. public record InstallPathResolutionResult( string ResolvedInstallPath, - string? InstallPathFromGlobalJson); + string? InstallPathFromGlobalJson, + PathSource PathSource); /// /// Resolves the install path using the following precedence: @@ -51,51 +56,39 @@ public record InstallPathResolutionResult( out string? error) { error = null; - string? resolvedInstallPath = null; - string? installPathFromGlobalJson = null; + string? installPathFromGlobalJson = globalJsonInfo?.GlobalJsonPath is not null + ? globalJsonInfo.SdkPath + : null; - if (globalJsonInfo?.GlobalJsonPath is not null) - { - installPathFromGlobalJson = globalJsonInfo.SdkPath; + // Resolution precedence: + // 1. Explicit --install-path always wins + // 2. global.json sdk-path + // 3. Existing user installation + // 4. Interactive prompt + // 5. Default install path - // If explicit path is provided, use it (it takes precedence over global.json) - // If no explicit path, fall back to global.json path - if (explicitInstallPath is not null) - { - resolvedInstallPath = explicitInstallPath; - } - else - { - resolvedInstallPath = installPathFromGlobalJson; - } + if (explicitInstallPath is not null) + { + return new InstallPathResolutionResult(explicitInstallPath, installPathFromGlobalJson, PathSource.Explicit); } - - if (resolvedInstallPath == null) + else if (installPathFromGlobalJson is not null) { - resolvedInstallPath = explicitInstallPath; + return new InstallPathResolutionResult(installPathFromGlobalJson, installPathFromGlobalJson, PathSource.GlobalJson); } - - if (resolvedInstallPath == null && currentDotnetInstallRoot is not null && currentDotnetInstallRoot.InstallType == InstallType.User) + else if (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; + return new InstallPathResolutionResult(currentDotnetInstallRoot.Path, installPathFromGlobalJson, PathSource.ExistingUserInstall); } - - if (resolvedInstallPath == null) + else if (interactive) { - if (interactive) - { - resolvedInstallPath = SpectreAnsiConsole.Prompt( - new TextPrompt($"Where should we install the {componentDescription} to?)") - .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); - } - else - { - // If no install path is specified, use the default install path - resolvedInstallPath = _dotnetInstaller.GetDefaultDotnetInstallPath(); - } + var prompted = SpectreAnsiConsole.Prompt( + new TextPrompt($"Where should we install the {componentDescription} to?") + .DefaultValue(_dotnetInstaller.GetDefaultDotnetInstallPath())); + return new InstallPathResolutionResult(prompted, installPathFromGlobalJson, PathSource.InteractivePrompt); + } + else + { + return new InstallPathResolutionResult(_dotnetInstaller.GetDefaultDotnetInstallPath(), installPathFromGlobalJson, PathSource.Default); } - - return new InstallPathResolutionResult(resolvedInstallPath, installPathFromGlobalJson); } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs index b898465e4753..c93aa120a335 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs @@ -1,8 +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.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; @@ -51,6 +53,9 @@ public List GetAdditionalAdminVersionsToMigrate( if (setDefaultInstall && currentInstallRoot?.InstallType == InstallType.Admin) { + // Track admin-to-user migration scenario + Activity.Current?.SetTag(TelemetryTagNames.InstallMigratingFromAdmin, true); + if (_options.Interactive) { var latestAdminVersion = _dotnetInstaller.GetLatestInstalledAdminVersion(); @@ -63,6 +68,7 @@ public List GetAdditionalAdminVersionsToMigrate( defaultValue: true)) { additionalVersions.Add(latestAdminVersion); + 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 e83f28680e74..9d9adf2e38e2 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.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 Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; @@ -48,26 +50,66 @@ private record WorkflowContext( string? InstallPathFromGlobalJson, string Channel, bool SetDefaultInstall, - bool? UpdateGlobalJson); + bool? UpdateGlobalJson, + string RequestSource, + PathSource PathSource); public InstallWorkflowResult Execute(InstallWorkflowOptions options) { + // Record telemetry for the install request + 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(TelemetryTagNames.ErrorType, "context_resolution_failed"); + Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "user"); + 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 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()); + Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "user"); return new InstallWorkflowResult(1, null); } + // Record resolved context telemetry + 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(TelemetryTagNames.DotnetRequestSource, context.RequestSource); + Activity.Current?.SetTag(TelemetryTagNames.DotnetRequested, VersionSanitizer.Sanitize(context.Channel)); + var resolved = CreateInstallRequest(context); - if (!ExecuteInstallations(context, resolved)) + // Record resolved version + Activity.Current?.SetTag(TelemetryTagNames.InstallResolvedVersion, resolved.ResolvedVersion?.ToString()); + + var installResult = ExecuteInstallations(context, resolved); + if (installResult is null) { + Activity.Current?.SetTag(TelemetryTagNames.ErrorType, "install_failed"); + Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "product"); return new InstallWorkflowResult(1, resolved); } ApplyPostInstallConfiguration(context, resolved); + Activity.Current?.SetTag(TelemetryTagNames.InstallResult, installResult.WasAlreadyInstalled ? "already_installed" : "installed"); InstallExecutor.DisplayComplete(); return new InstallWorkflowResult(0, resolved); } @@ -107,6 +149,13 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) pathResolution.ResolvedInstallPath, installPathCameFromGlobalJson: pathResolution.InstallPathFromGlobalJson is not null); + // Classify how the version/channel was determined for telemetry + string requestSource = channelFromGlobalJson is not null + ? "default-globaljson" + : options.VersionOrChannel is not null + ? "explicit" + : "default-latest"; + return new WorkflowContext( options, walkthrough, @@ -116,7 +165,9 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) pathResolution.InstallPathFromGlobalJson, channel, setDefaultInstall, - updateGlobalJson); + updateGlobalJson, + requestSource, + pathResolution.PathSource); } private InstallExecutor.ResolvedInstallRequest CreateInstallRequest(WorkflowContext context) @@ -130,7 +181,7 @@ private InstallExecutor.ResolvedInstallRequest CreateInstallRequest(WorkflowCont context.Options.RequireMuxerUpdate); } - private bool ExecuteInstallations(WorkflowContext context, InstallExecutor.ResolvedInstallRequest resolved) + private InstallExecutor.InstallResult? ExecuteInstallations(WorkflowContext context, InstallExecutor.ResolvedInstallRequest resolved) { // Gather all user prompts before starting any downloads. // Users may walk away after seeing download progress begin, expecting no more prompts. @@ -147,7 +198,7 @@ private bool ExecuteInstallations(WorkflowContext context, InstallExecutor.Resol if (!installResult.Success) { - return false; + return null; } InstallExecutor.ExecuteAdditionalInstalls( @@ -159,7 +210,7 @@ private bool ExecuteInstallations(WorkflowContext context, InstallExecutor.Resol context.Options.NoProgress, context.Options.RequireMuxerUpdate); - return true; + return installResult; } private void ApplyPostInstallConfiguration(WorkflowContext context, InstallExecutor.ResolvedInstallRequest resolved) 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/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index 6a90b05c8531..a56227611091 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; @@ -59,9 +60,8 @@ public DotnetInstallManager(IEnvironmentProvider? environmentProvider = null) } else { - // For now, on non-Windows platforms, consider an install a user install only if it's in the default install location - // https://github.com/dotnet/sdk/issues/52668 tracks improving this - bool isAdminInstall = !currentInstallRoot.Path.StartsWith(GetDefaultDotnetInstallPath()); + // For non-Windows platforms, determine based on path location + 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); @@ -113,23 +113,23 @@ 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() ); - DotnetInstall? newInstall = InstallerOrchestratorSingleton.Instance.Install(request); - if (newInstall == null) + 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 { - 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/DotnetupPaths.cs b/src/Installer/dotnetup/DotnetupPaths.cs new file mode 100644 index 000000000000..60db1604b7b8 --- /dev/null +++ b/src/Installer/dotnetup/DotnetupPaths.cs @@ -0,0 +1,113 @@ +// 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; + +/// +/// 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 macOS: ~/Library/Application Support/dotnetup + /// On Linux: $XDG_DATA_HOME/dotnetup or ~/.local/share/dotnetup + /// + /// + /// Can be overridden via DOTNET_TESTHOOK_DOTNETUP_DATA_DIR environment variable. + /// Throws if the base directory cannot be determined. + /// + 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; + } + + var baseDir = GetBaseDirectory(); + if (string.IsNullOrEmpty(baseDir)) + { + throw new InvalidOperationException("Could not determine the local application data directory. Ensure the environment is properly configured."); + } + + _dataDirectory = Path.Combine(baseDir, DotnetupFolderName); + return _dataDirectory; + } + } + + /// + /// Gets the path to the dotnetup manifest file. + /// + /// + /// 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; + } + + return Path.Combine(DataDirectory, ManifestFileName); + } + } + + /// + /// Gets the path to the telemetry first-run sentinel file. + /// + public static string TelemetrySentinelPath => Path.Combine(DataDirectory, TelemetrySentinelFileName); + + /// + /// Ensures the data directory exists, creating it if necessary. + /// + /// True if the directory exists or was created; false otherwise. + public static bool EnsureDataDirectoryExists() + { + try + { + var dataDir = DataDirectory; + if (!Directory.Exists(dataDir)) + { + Directory.CreateDirectory(dataDir); + } + return true; + } + catch + { + return false; + } + } + + /// + /// Gets the base directory for dotnetup data storage. + /// + private static string? GetBaseDirectory() + { + // 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/DotnetupSharedManifest.cs b/src/Installer/dotnetup/DotnetupSharedManifest.cs index ffffffae9306..cda8ce72ab1c 100644 --- a/src/Installer/dotnetup/DotnetupSharedManifest.cs +++ b/src/Installer/dotnetup/DotnetupSharedManifest.cs @@ -1,12 +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.IO; -using System.Linq; using System.Text.Json; -using System.Threading; +using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -40,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() @@ -70,7 +57,24 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v AssertHasFinalizationMutex(); EnsureManifestExists(); - var json = File.ReadAllText(ManifestPath); + string json; + try + { + json = File.ReadAllText(ManifestPath); + } + catch (FileNotFoundException) + { + // Manifest doesn't exist yet - return empty list + return []; + } + 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 +94,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 3314b89d3e7e..27907ecaddc4 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -3,13 +3,22 @@ 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; +/// +/// 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(); @@ -22,17 +31,32 @@ 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) { + // 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) { - Console.WriteLine($"\nCould not resolve version for channel '{installRequest.Channel.Name}'."); - return null; + // Channel format was valid, but the version doesn't exist + throw new DotnetInstallException( + DotnetInstallErrorCode.VersionNotFound, + $"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()); } DotnetInstall install = new( @@ -46,10 +70,18 @@ private InstallerOrchestratorSingleton() // Check if the install already exists and we don't need to do anything using (var finalizeLock = modifyInstallStateMutex()) { + if (!finalizeLock.HasHandle) + { + // 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(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)) { Console.WriteLine($"\n{componentDescription} {versionToInstall} is already installed, skipping installation."); - return install; + return new InstallResult(install, WasAlreadyInstalled: true); } // Also check if the component files already exist on disk (e.g., runtime files from SDK install) @@ -59,7 +91,7 @@ private InstallerOrchestratorSingleton() Console.WriteLine($"\n{componentDescription} {versionToInstall} files already exist, adding to manifest."); DotnetupSharedManifest manifestManager = new(customManifestPath); manifestManager.AddInstalledVersion(install); - return install; + return new InstallResult(install, WasAlreadyInstalled: true); } // Fail fast: if the muxer must be updated and it is currently locked, @@ -79,9 +111,17 @@ private InstallerOrchestratorSingleton() // Extract and commit the install to the directory using (var finalizeLock = modifyInstallStateMutex()) { + if (!finalizeLock.HasHandle) + { + // 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(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)) { - return install; + return new InstallResult(install, WasAlreadyInstalled: true); } installer.Commit(); @@ -95,11 +135,11 @@ private InstallerOrchestratorSingleton() else { Console.Error.WriteLine($"Installation validation failed: {validationFailure}"); - return null; + return new InstallResult(null, WasAlreadyInstalled: false); } } - return install; + return new InstallResult(install, WasAlreadyInstalled: false); } /// diff --git a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs index 5903cd319f7f..a9ca3090e072 100644 --- a/src/Installer/dotnetup/NonUpdatingProgressTarget.cs +++ b/src/Installer/dotnetup/NonUpdatingProgressTarget.cs @@ -1,10 +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.Reflection.Metadata.Ecma335; -using System.Text; +using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -12,33 +9,24 @@ public class NonUpdatingProgressTarget : IProgressTarget { public IProgressReporter CreateProgressReporter() => new Reporter(); - class Reporter : IProgressReporter + private sealed class Reporter : IProgressReporter { - List _tasks = new(); - public IProgressTask AddTask(string description, double maxValue) { - var task = new ProgressTaskImpl(description) - { - MaxValue = maxValue - }; - _tasks.Add(task); - Spectre.Console.AnsiConsole.WriteLine(description + "..."); + var task = new ProgressTaskImpl(description) { MaxValue = maxValue }; + AnsiConsole.WriteLine(description + "..."); return task; } + public void Dispose() { - foreach (var task in _tasks) - { - task.Complete(); - } } } - class ProgressTaskImpl : IProgressTask + private sealed class ProgressTaskImpl : IProgressTask { - bool _completed = false; - double _value; + private double _value; + private bool _completed; public ProgressTaskImpl(string description) { @@ -51,22 +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 Complete() - { - if (!_completed) - { - Spectre.Console.AnsiConsole.MarkupLine($"[green]Completed:[/] {Description}"); - _completed = true; - } - } } } diff --git a/src/Installer/dotnetup/Program.cs b/src/Installer/dotnetup/Program.cs index d56acd2e8099..4d2ab0a87b43 100644 --- a/src/Installer/dotnetup/Program.cs +++ b/src/Installer/dotnetup/Program.cs @@ -1,16 +1,59 @@ -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.Installation.Internal; +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); + + // Set up callback to notify user when waiting for another dotnetup process + ScopedMutex.OnWaitingForMutex = () => { - // Handle --debug flag using the standard .NET SDK pattern - // This is DEBUG-only and removes the --debug flag from args - DotnetupDebugHelper.HandleDebugSwitch(ref args); + Console.WriteLine("Another dotnetup process is running. Waiting for it to finish..."); + }; - return Parser.Invoke(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) + : null; + + try + { + var result = Parser.Invoke(args); + rootActivity?.SetTag(TelemetryTagNames.ExitCode, 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(TelemetryTagNames.ExitCode, 1); + + // Log the error and return non-zero exit code + 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/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index b1998bf09942..230ff3d38dc8 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -1,10 +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; -using System.Collections.Generic; -using System.Reflection.Metadata.Ecma335; -using System.Text; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -13,14 +9,14 @@ 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; public Reporter() { - TaskCompletionSource tcs = new TaskCompletionSource(); + TaskCompletionSource tcs = new(); var progressTask = AnsiConsole.Progress().StartAsync(async ctx => { tcs.SetResult(ctx); @@ -41,9 +37,10 @@ public void Dispose() } } - class ProgressTaskImpl : IProgressTask + private sealed class ProgressTaskImpl : IProgressTask { private readonly Spectre.Console.ProgressTask _task; + public ProgressTaskImpl(Spectre.Console.ProgressTask task) { _task = task; @@ -54,11 +51,13 @@ 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; diff --git a/src/Installer/dotnetup/Strings.resx b/src/Installer/dotnetup/Strings.resx index 1e20a3496fde..37e243fdf876 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/BuildInfo.cs b/src/Installer/dotnetup/Telemetry/BuildInfo.cs new file mode 100644 index 000000000000..10b4efa2fb80 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/BuildInfo.cs @@ -0,0 +1,54 @@ +// 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 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 => s_buildInfo.Value.Version; + + /// + /// Gets the short commit SHA (7 characters, e.g., "abc123d"). + /// Returns "unknown" if not available. + /// + public static string CommitSha => s_buildInfo.Value.CommitSha; + + /// + /// 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/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs new file mode 100644 index 000000000000..315f2489ed93 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -0,0 +1,236 @@ +// 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 Azure.Monitor.OpenTelemetry.Exporter; +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. + /// 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"; + + /// + /// 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 + + // IMPORTANT: Do NOT add auto-instrumentation (e.g. AddHttpClientInstrumentation) + // without reviewing PII implications. + + // 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); + if (activity != null) + { + 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(TelemetryTagNames.Caller, "dotnetup"); + activity?.SetTag(TelemetryTagNames.SessionId, _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); + ErrorCodeMapper.ApplyErrorTags(activity, errorInfo, errorCode); + } + + /// + /// 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; + } + + // 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(TelemetryTagNames.Caller, "dotnetup"); + + 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); + } + } + } + + /// + /// Flushes any pending telemetry. + /// + /// Maximum time to wait for flush (default 5 seconds). + public void Flush(int timeoutMilliseconds = 5000) + { + try + { + _tracerProvider?.ForceFlush(timeoutMilliseconds); + } + catch + { + // Never let telemetry flush failures crash the app + } + } + + /// + /// 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/ErrorCategoryClassifier.cs b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs new file mode 100644 index 000000000000..aa4ae59e9e51 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs @@ -0,0 +1,200 @@ +// 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; + +/// +/// 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 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 + /// get out of sync. + /// + internal static (string ErrorType, ErrorCategory Category, string? Details) ClassifyIOErrorByHResult(int hResult) + { + return hResult switch + { + // 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 new file mode 100644 index 000000000000..acd3df73cf95 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/ErrorCodeMapper.cs @@ -0,0 +1,276 @@ +// 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.Text; +using Microsoft.Dotnet.Installation; +using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; + +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). +/// 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? StackTrace = null); + +/// +/// Maps exceptions to error info for telemetry. +/// +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 { StackTrace: { } stackTrace }) + activity.SetTag("error.stack_trace", stackTrace); + } + + /// + /// 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]); + } + + // 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); + } + + 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) 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: ErrorCategoryClassifier.ClassifyHttpError(httpEx.StatusCode), + StatusCode: (int?)httpEx.StatusCode, + StackTrace: fullStackTrace), + + // 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: fullStackTrace), + + // Permission denied - user environment issue (needs elevation or different permissions) + UnauthorizedAccessException => new ExceptionErrorInfo( + "PermissionDenied", + Category: ErrorCategory.User, + StackTrace: fullStackTrace), + + // Directory not found - could be user specified bad path + DirectoryNotFoundException => new ExceptionErrorInfo( + "DirectoryNotFound", + Category: ErrorCategory.User, + StackTrace: fullStackTrace), + + IOException ioEx => MapIOException(ioEx, fullStackTrace), + + // User cancelled the operation + OperationCanceledException => new ExceptionErrorInfo( + "Cancelled", + Category: ErrorCategory.User, + StackTrace: fullStackTrace), + + // Invalid argument - user provided bad input + ArgumentException argEx => new ExceptionErrorInfo( + "InvalidArgument", + Category: ErrorCategory.User, + Details: argEx.ParamName, + StackTrace: fullStackTrace), + + // Invalid operation - usually a bug in our code + InvalidOperationException => new ExceptionErrorInfo( + "InvalidOperation", + Category: ErrorCategory.Product, + StackTrace: fullStackTrace), + + // Not supported - could be user trying unsupported scenario + NotSupportedException => new ExceptionErrorInfo( + "NotSupported", + Category: ErrorCategory.User, + StackTrace: fullStackTrace), + + // Timeout - network/environment issue outside our control + TimeoutException => new ExceptionErrorInfo( + "Timeout", + Category: ErrorCategory.User, + StackTrace: fullStackTrace), + + // Unknown exceptions default to product (fail-safe - we should handle known cases) + _ => new ExceptionErrorInfo( + ex.GetType().Name, + Category: ErrorCategory.Product, + StackTrace: fullStackTrace) + }; + } + + /// + /// Gets error info for a DotnetInstallException, enriching with inner exception details + /// for network-related and IO-related errors. + /// + private static ExceptionErrorInfo GetInstallExceptionErrorInfo( + DotnetInstallException installEx) + { + var errorCode = installEx.ErrorCode; + var baseCategory = ErrorCategoryClassifier.ClassifyInstallError(errorCode); + var details = installEx.Version is not null ? VersionSanitizer.Sanitize(installEx.Version) : null; + int? httpStatus = null; + + if (ErrorCategoryClassifier.IsNetworkRelatedErrorCode(errorCode) && installEx.InnerException is not null) + { + var (refinedCategory, innerHttpStatus, innerDetails) = ErrorCategoryClassifier.AnalyzeNetworkException(installEx.InnerException); + baseCategory = refinedCategory; + httpStatus = innerHttpStatus; + + if (innerDetails is not null) + { + details = details is not null ? $"{details};{innerDetails}" : innerDetails; + } + } + + if (ErrorCategoryClassifier.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, + StatusCode: httpStatus, + Details: details); + } + + 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); + + return new ExceptionErrorInfo( + errorType, + Category: category, + HResult: ioEx.HResult, + Details: details, + StackTrace: stackTrace); + } + + + + /// + /// 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. + /// + private static string? GetFullStackTrace(Exception ex) + { + try + { + var sb = new StringBuilder(); + if (ex.StackTrace is { } trace) + { + sb.Append(trace); + } + + var inner = ex.InnerException; + // Limit depth to prevent infinite loops and overly long strings + const int maxDepth = 10; + var depth = 0; + while (inner != null && depth < maxDepth) + { + sb.AppendLine(); + sb.AppendLine($"Inner Exception: {inner.GetType().FullName}"); + if (inner.StackTrace is { } innerTrace) + { + sb.Append(innerTrace); + } + inner = inner.InnerException; + depth++; + } + + 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/FirstRunNotice.cs b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs new file mode 100644 index 000000000000..ef1d33083454 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/FirstRunNotice.cs @@ -0,0 +1,98 @@ +// 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; + +/// +/// 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 +{ + /// + /// 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. + /// + /// 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; + } + + // Respect DOTNET_NOLOGO to suppress notice (same behavior as .NET SDK) + if (IsNoLogoSet()) + { + return; + } + + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; + 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 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). + /// + public static bool IsFirstRun() + { + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; + return !string.IsNullOrEmpty(sentinelPath) && !File.Exists(sentinelPath); + } + + private static void ShowNotice() + { + // 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) + { + try + { + DotnetupPaths.EnsureDataDirectoryExists(); + + // Write version info to the sentinel for debugging purposes + File.WriteAllText(sentinelPath, $"dotnetup telemetry notice shown: {DateTime.UtcNow:O}\nVersion: {BuildInfo.Version}\nCommit: {BuildInfo.CommitSha}\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/Telemetry/TelemetryCommonProperties.cs b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs new file mode 100644 index 000000000000..7e83f86075c9 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/TelemetryCommonProperties.cs @@ -0,0 +1,149 @@ +// 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); + private static readonly Lazy s_llmEnvironment = new(DetectLLMEnvironment); + 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. + /// + public static IEnumerable> GetCommonAttributes(string sessionId) + { + var attributes = new Dictionary + { + ["session.id"] = sessionId, + ["device.id"] = s_deviceId.Value, + ["os.platform"] = RuntimeInformation.OSDescription, + ["os.version"] = Environment.OSVersion.VersionString, + ["process.arch"] = RuntimeInformation.ProcessArchitecture.ToString(), + ["ci.detected"] = s_isCIEnvironment.Value, + ["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; + } + + /// + /// 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 GetDeviceId() + { + try + { + // Reuse the SDK's device ID getter for consistency + return DeviceIdGetter.GetDeviceId(); + } + catch + { + // Fallback to empty string if device ID retrieval fails (consistent with SDK behavior) + return string.Empty; + } + } + + private static bool DetectCIEnvironment() + { + try + { + // Reuse the SDK's CI detection + var detector = new CIEnvironmentDetectorForTelemetry(); + return detector.IsCIEnvironment(); + } + catch + { + return false; + } + } + + 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 +#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 + } + + internal static string GetVersion() + { + 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; + } +} diff --git a/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs b/src/Installer/dotnetup/Telemetry/TelemetryTagNames.cs new file mode 100644 index 000000000000..1e342300cd70 --- /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 ErrorStackTrace = "error.stack_trace"; + 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"; +} diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json new file mode 100644 index 000000000000..bf43b352cf33 --- /dev/null +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -0,0 +1,738 @@ +{ + "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" + }, + { + "durationMs": 7776000000, + "displayName": "Last 90 days" + } + ], + "allowCustom": true + }, + "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\" 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, + "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\" 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, + "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}';\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, + "aggregation": 4, + "title": "Product Success Rate Over Time (by Version)", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "timechart", + "chartSettings": { + "ySettings": { + "min": 0, + "max": 100 + } + } + }, + "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": { + "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": 1, + "content": { + "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": { + "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| 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 Runtime Versions", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "barchart" + }, + "customWidth": "50", + "name": "installed-runtime-versions" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "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 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" + }, + "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 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 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\"]), \"(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, + "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 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, + "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 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 Stack Trace", + "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}';\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 Type", + "noDataMessage": "No error type data available.", + "queryType": 0, + "resourceType": "microsoft.insights/components", + "visualization": "table" + }, + "customWidth": "50", + "name": "errors-by-throw-site" + }, + { + "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 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.", + "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| 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" +} \ No newline at end of file diff --git a/src/Installer/dotnetup/docs/dotnetup-telemetry.md b/src/Installer/dotnetup/docs/dotnetup-telemetry.md new file mode 100644 index 000000000000..d7a1c6aec537 --- /dev/null +++ b/src/Installer/dotnetup/docs/dotnetup-telemetry.md @@ -0,0 +1,72 @@ +# 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, sanitized error details + (no file paths), and the full stack trace with exception messages removed + +## Crash Exception Telemetry + +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 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). + +## 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 diff --git a/src/Installer/dotnetup/dotnetup.csproj b/src/Installer/dotnetup/dotnetup.csproj index 5b5edaca3fa7..9795fa78669d 100644 --- a/src/Installer/dotnetup/dotnetup.csproj +++ b/src/Installer/dotnetup/dotnetup.csproj @@ -27,8 +27,11 @@ + + + @@ -39,6 +42,10 @@ + + + + diff --git a/src/Installer/dotnetup/xlf/Strings.cs.xlf b/src/Installer/dotnetup/xlf/Strings.cs.xlf index 11330348e0ce..dd6f3acdbefe 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 9eb3c7f21a1c..39e7ca1d4baf 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 52cd69a8977e..a940dcde7258 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 49a4c1a14c06..99251b0f25b2 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 b1cd2eb5d5d7..c1d4857cba2c 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 512138f04dcd..c39a03915cbb 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 df6e372dcfb2..11b6525f9851 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 d9f27136ac56..d6631586c275 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 a33032ddd23a..20b66ee14628 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 629ad1c4eb93..a33f0a6abed6 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 a59db3c01d92..3655d6660d12 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 d78fee14b353..490ea0ab1b18 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 74df8bb311d3..c0570b211cbe 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 9769a3a8999a..935ffc6c984b 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, @@ -34,6 +34,9 @@ "stopAtEntry": false, "logging": { "moduleLoad": false + }, + "env": { + "DOTNETUP_DEV_BUILD": "1" } }, { @@ -163,4 +166,4 @@ }, ] } -} +} \ No newline at end of file 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 @@ + + diff --git a/test/dotnetup.Tests/ChannelVersionResolverTests.cs b/test/dotnetup.Tests/ChannelVersionResolverTests.cs index 761c158c48e2..e8c0f62ecadf 100644 --- a/test/dotnetup.Tests/ChannelVersionResolverTests.cs +++ b/test/dotnetup.Tests/ChannelVersionResolverTests.cs @@ -99,6 +99,41 @@ public void GetLatestVersionForChannel_Preview_ReturnsLatestPreviewVersion() ); } + [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)] + [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)); + } + + [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 + [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)); + } [Fact] public void GetSupportedChannels_WithFeatureBands_IncludesFeatureBandChannels() { diff --git a/test/dotnetup.Tests/DnupE2Etest.cs b/test/dotnetup.Tests/DnupE2Etest.cs index dafa35211113..e567ddfa27ff 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/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/DotnetArchiveExtractorTests.cs b/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs index 00dafeead0a8..7371729158ce 100644 --- a/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs +++ b/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs @@ -19,6 +19,7 @@ namespace Microsoft.DotNet.Tools.Dotnetup.Tests; /// /// Tests for DotnetArchiveExtractor, particularly error handling scenarios. /// +[Collection("ActivitySourceTests")] public class DotnetArchiveExtractorTests { private readonly ITestOutputHelper _log; @@ -53,8 +54,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/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs new file mode 100644 index 000000000000..6b49e1fa941e --- /dev/null +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -0,0 +1,226 @@ +// 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 Microsoft.Dotnet.Installation; +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); + // Details contain the human-readable error constant name + 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); + // Details contain the human-readable error constant name + 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); + // Details contain the human-readable error constant name + 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_IncludesInnerExceptionInStackTrace() + { + 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); + // 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] + 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_ThrownException_HasStackTrace() + { + // Throw from a method to get a real stack trace + var ex = ThrowTestException(); + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + Assert.Equal("InvalidOperation", info.ErrorType); + Assert.NotNull(info.StackTrace); + Assert.Contains("ThrowTestException", info.StackTrace); + } + + [Fact] + public void GetErrorInfo_AllFieldsPopulated_ForIOExceptionWithInnerException() + { + // 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 inner exception type is included in stack trace + Assert.Equal("IOException", info.ErrorType); + Assert.NotNull(info.StackTrace); + Assert.Contains("System.UnauthorizedAccessException", info.StackTrace); + } + + [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); + // Details contain the human-readable error constant name + 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_IncludesInnerExceptionsInStackTrace() + { + // Create a chain of typed exceptions + Exception ex = new InvalidOperationException("Root"); + for (int i = 0; i < 10; i++) + { + ex = new IOException($"Wrapper {i}", ex); + } + + var info = ErrorCodeMapper.GetErrorInfo(ex); + + // 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] + 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); + // Details contain the human-readable error constant name + 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); + // Details contain the human-readable error constant name + Assert.Equal("ERROR_ALREADY_EXISTS", info.Details); + } + + private class CustomTestException : Exception + { + public CustomTestException(string message) : base(message) { } + } +} diff --git a/test/dotnetup.Tests/InstallPathResolverTests.cs b/test/dotnetup.Tests/InstallPathResolverTests.cs index f173c50cbc77..ea3647379069 100644 --- a/test/dotnetup.Tests/InstallPathResolverTests.cs +++ b/test/dotnetup.Tests/InstallPathResolverTests.cs @@ -18,7 +18,7 @@ namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public class InstallPathResolverTests(ITestOutputHelper output) { private readonly InstallPathResolver _resolver = new(new DotnetInstallManager()); - + // Use platform-appropriate temp paths for test data private static readonly string TempDir = Path.GetTempPath(); private static readonly string ExplicitPath = Path.Combine(TempDir, "explicit-dotnet"); @@ -47,6 +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(PathSource.Explicit); } [Fact] @@ -65,6 +66,7 @@ public void Resolve_UsesGlobalJsonPath_WhenNoExplicitPath() error.Should().BeNull(); result.Should().NotBeNull(); result!.ResolvedInstallPath.Should().Be(GlobalJsonPath); + result.PathSource.Should().Be(PathSource.GlobalJson); } [Fact] @@ -82,6 +84,7 @@ public void Resolve_MatchingPathsSucceed() error.Should().BeNull(); result!.ResolvedInstallPath.Should().Be(SamePath); + result.PathSource.Should().Be(PathSource.Explicit); } [Fact] @@ -96,7 +99,9 @@ public void Resolve_UsesExplicitPath_WhenNoGlobalJson() out string? error); error.Should().BeNull(); + result.Should().NotBeNull(); result!.ResolvedInstallPath.Should().Be(ExplicitPath); + result.PathSource.Should().Be(PathSource.Explicit); } [Fact] @@ -114,6 +119,7 @@ public void Resolve_UsesDefaultPath_WhenNothingSpecified() error.Should().BeNull(); result.Should().NotBeNull(); result!.ResolvedInstallPath.Should().Be(installManager.GetDefaultDotnetInstallPath()); + result.PathSource.Should().Be(PathSource.Default); } [Fact] @@ -132,6 +138,116 @@ public void Resolve_UsesCurrentUserInstall_WhenNoExplicitOrGlobalJson() error.Should().BeNull(); result!.ResolvedInstallPath.Should().Be("/user/dotnet"); + result.PathSource.Should().Be(PathSource.ExistingUserInstall); + } + + /// + /// Regression: before the refactor, passing an explicit path without a global.json + /// caused the method to return null because all logic was gated behind the global.json check. + /// + [Fact] + public void Resolve_ExplicitPath_WithoutGlobalJson_ReturnsNonNull() + { + var result = _resolver.Resolve( + explicitInstallPath: ExplicitPath, + globalJsonInfo: null, + currentDotnetInstallRoot: null, + interactive: false, + componentDescription: ".NET SDK", + out string? error); + + error.Should().BeNull(); + result.Should().NotBeNull("explicit path must work even without global.json"); + result!.ResolvedInstallPath.Should().Be(ExplicitPath); + result.PathSource.Should().Be(PathSource.Explicit); + result.InstallPathFromGlobalJson.Should().BeNull(); + } + + /// + /// Explicit path should beat an existing user install. + /// + [Fact] + public void Resolve_ExplicitPath_TakesPrecedenceOverExistingUserInstall() + { + var installRoot = new DotnetInstallRoot("/user/dotnet", InstallerUtilities.GetDefaultInstallArchitecture()); + var currentInstall = new DotnetInstallRootConfiguration(installRoot, InstallType.User, IsFullyConfigured: true); + + var result = _resolver.Resolve( + explicitInstallPath: ExplicitPath, + globalJsonInfo: null, + currentDotnetInstallRoot: currentInstall, + interactive: false, + componentDescription: ".NET SDK", + out string? error); + + error.Should().BeNull(); + result!.ResolvedInstallPath.Should().Be(ExplicitPath, "explicit path should win over existing user install"); + result.PathSource.Should().Be(PathSource.Explicit); + } + + /// + /// global.json path should beat an existing user install when no explicit path is given. + /// + [Fact] + public void Resolve_GlobalJson_TakesPrecedenceOverExistingUserInstall() + { + var installRoot = new DotnetInstallRoot("/user/dotnet", InstallerUtilities.GetDefaultInstallArchitecture()); + var currentInstall = new DotnetInstallRootConfiguration(installRoot, InstallType.User, IsFullyConfigured: true); + var globalJsonInfo = CreateGlobalJsonInfo(GlobalJsonPath); + + var result = _resolver.Resolve( + explicitInstallPath: null, + globalJsonInfo: globalJsonInfo, + currentDotnetInstallRoot: currentInstall, + interactive: false, + componentDescription: ".NET SDK", + out string? error); + + error.Should().BeNull(); + result!.ResolvedInstallPath.Should().Be(GlobalJsonPath, "global.json should win over existing user install"); + result.PathSource.Should().Be(PathSource.GlobalJson); + } + + /// + /// Regression: without global.json, the default fallback must not return null. + /// + [Fact] + public void Resolve_NoInputs_ReturnsDefaultPath_NotNull() + { + var result = _resolver.Resolve( + explicitInstallPath: null, + globalJsonInfo: null, + currentDotnetInstallRoot: null, + interactive: false, + componentDescription: ".NET SDK", + out string? error); + + error.Should().BeNull(); + result.Should().NotBeNull("default path fallback must always produce a result"); + result!.ResolvedInstallPath.Should().NotBeNullOrEmpty(); + result.PathSource.Should().Be(PathSource.Default); + } + + /// + /// Admin installs should not be picked up — only User installs. + /// + [Fact] + public void Resolve_AdminInstall_FallsToDefault_NotExistingInstall() + { + var installRoot = new DotnetInstallRoot("/admin/dotnet", InstallerUtilities.GetDefaultInstallArchitecture()); + var currentInstall = new DotnetInstallRootConfiguration(installRoot, InstallType.Admin, IsFullyConfigured: true); + + var result = _resolver.Resolve( + explicitInstallPath: null, + globalJsonInfo: null, + currentDotnetInstallRoot: currentInstall, + interactive: false, + componentDescription: ".NET SDK", + out string? error); + + error.Should().BeNull(); + result!.ResolvedInstallPath.Should().NotBe("/admin/dotnet", "admin installs should not be used as fallback"); + 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 new file mode 100644 index 000000000000..a859f0ec20f3 --- /dev/null +++ b/test/dotnetup.Tests/InstallTelemetryTests.cs @@ -0,0 +1,551 @@ +// 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.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_ProgramFilesDotnet_ReturnsAdmin() + { + 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("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); + } + + [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: PathSource.GlobalJson); + + 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: PathSource.GlobalJson); + + 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: 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_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); + } + + [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: "ERROR_DISK_FULL", + StackTrace: "at SomeMethod() in InstallExecutor.cs:line 42", + 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("ERROR_DISK_FULL", a.GetTagItem("error.details")); + Assert.Equal("at SomeMethod() in InstallExecutor.cs:line 42", a.GetTagItem("error.stack_trace")); + } + + [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, + StackTrace: null, + 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, + StackTrace: 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, + StackTrace: 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.stack_trace")); + 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, + StackTrace: 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; + } +} + +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); + } +} 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/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..6a5e324c2d64 --- /dev/null +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -0,0 +1,429 @@ +// 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 Xunit; +using UrlSanitizer = Microsoft.Dotnet.Installation.Internal.UrlSanitizer; + +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; + // OSDescription returns the full OS description (e.g., "Microsoft Windows 10.0.26200") + Assert.False(string.IsNullOrEmpty(osPlatform)); + } + + [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 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] + 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); + } +} + +public class FirstRunNoticeTests : IDisposable +{ + private const string NoLogoEnvVar = "DOTNET_NOLOGO"; + private const string DataDirEnvVar = "DOTNET_TESTHOOK_DOTNETUP_DATA_DIR"; + + private readonly string _tempDir; + private readonly string? _originalDataDir; + + public FirstRunNoticeTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"dotnetup-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + + // 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()); + } + + [Fact] + public void ShowIfFirstRun_CreatesSentinelFile() + { + // Save and clear DOTNET_NOLOGO to ensure test runs the full path + var originalNoLogo = Environment.GetEnvironmentVariable(NoLogoEnvVar); + Environment.SetEnvironmentVariable(NoLogoEnvVar, null); + + try + { + var sentinelPath = DotnetupPaths.TelemetrySentinelPath; + Assert.NotNull(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()); + } + finally + { + Environment.SetEnvironmentVariable(NoLogoEnvVar, originalNoLogo); + } + } + + [Fact] + public void ShowIfFirstRun_DoesNotCreateSentinel_WhenTelemetryDisabled() + { + // 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)); + } +} + +/// +/// 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"; + + [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"); + } +} 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/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) { diff --git a/test/dotnetup.Tests/VersionSanitizerTests.cs b/test/dotnetup.Tests/VersionSanitizerTests.cs new file mode 100644 index 000000000000..c09bc4e07906 --- /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.Installation.Internal; + +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 +} 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) + + + + +