diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs index b517a9b351d1..4de814345ed7 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs @@ -58,6 +58,9 @@ public enum DotnetInstallErrorCode /// The dotnetup installation manifest is corrupted. LocalManifestCorrupted, + + /// The dotnetup installation manifest was modified externally and is now corrupted. + LocalManifestUserCorrupted, } /// diff --git a/src/Installer/dotnetup/CommandBase.cs b/src/Installer/dotnetup/CommandBase.cs index afe4c6e6bfa8..572de9b417c3 100644 --- a/src/Installer/dotnetup/CommandBase.cs +++ b/src/Installer/dotnetup/CommandBase.cs @@ -47,6 +47,16 @@ public int Execute() AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]"); return 1; } + catch (Exception ex) + { + // Unexpected errors - still record telemetry so error_type is populated + DotnetupTelemetry.Instance.RecordException(_commandActivity, ex); + AnsiConsole.MarkupLine($"[red]Error: {ex.Message.EscapeMarkup()}[/]"); +#if DEBUG + Console.Error.WriteLine(ex.StackTrace); +#endif + return 1; + } finally { _commandActivity?.SetTag(TelemetryTagNames.ExitCode, _exitCode); @@ -91,6 +101,14 @@ protected void RecordFailure(string reason, string? message = null, string categ { _commandActivity?.SetTag(TelemetryTagNames.ErrorMessage, message); } + + // Also set LastErrorInfo so the error propagates to the root span + // via ApplyLastErrorToActivity in Program.cs. + var errorCategory = string.Equals(category, "user", StringComparison.OrdinalIgnoreCase) + ? ErrorCategory.User + : ErrorCategory.Product; + DotnetupTelemetry.Instance.SetLastErrorInfo( + new ExceptionErrorInfo(reason, errorCategory, Details: message)); } /// diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index cc403325f263..0afefc53c30a 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -67,6 +67,20 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) Console.Error.WriteLine(error); Activity.Current?.SetTag(TelemetryTagNames.ErrorType, "context_resolution_failed"); Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "user"); + DotnetupTelemetry.Instance.SetLastErrorInfo( + new ExceptionErrorInfo("context_resolution_failed", ErrorCategory.User)); + return new InstallWorkflowResult(1, null); + } + + // Block install paths that point to existing files (not directories) + if (File.Exists(context.InstallPath)) + { + Console.Error.WriteLine($"Error: The install path '{context.InstallPath}' is an existing file, not a directory. " + + "Please specify a directory path for the installation."); + Activity.Current?.SetTag(TelemetryTagNames.ErrorType, "install_path_is_file"); + Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "user"); + DotnetupTelemetry.Instance.SetLastErrorInfo( + new ExceptionErrorInfo("install_path_is_file", ErrorCategory.User)); return new InstallWorkflowResult(1, null); } @@ -80,6 +94,8 @@ public InstallWorkflowResult Execute(InstallWorkflowOptions options) Activity.Current?.SetTag(TelemetryTagNames.InstallPathType, "admin"); Activity.Current?.SetTag(TelemetryTagNames.InstallPathSource, context.PathSource.ToString().ToLowerInvariant()); Activity.Current?.SetTag(TelemetryTagNames.ErrorCategory, "user"); + DotnetupTelemetry.Instance.SetLastErrorInfo( + new ExceptionErrorInfo("admin_path_blocked", ErrorCategory.User)); return new InstallWorkflowResult(1, null); } diff --git a/src/Installer/dotnetup/DotnetupSharedManifest.cs b/src/Installer/dotnetup/DotnetupSharedManifest.cs index f77dbb41926e..8560c345e379 100644 --- a/src/Installer/dotnetup/DotnetupSharedManifest.cs +++ b/src/Installer/dotnetup/DotnetupSharedManifest.cs @@ -1,6 +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.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.Dotnet.Installation.Internal; @@ -20,8 +22,8 @@ private void EnsureManifestExists() { if (!File.Exists(ManifestPath)) { - Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); - File.WriteAllText(ManifestPath, JsonSerializer.Serialize((List)[], DotnetupManifestJsonContext.Default.ListDotnetInstall)); + var json = JsonSerializer.Serialize((List)[], DotnetupManifestJsonContext.Default.ListDotnetInstall); + WriteManifestWithChecksum(json); } } @@ -86,16 +88,27 @@ public IEnumerable GetInstalledVersions(IInstallationValidator? v { validInstalls = validInstalls.Except(invalids).ToList(); var newJson = JsonSerializer.Serialize(validInstalls, DotnetupManifestJsonContext.Default.ListDotnetInstall); - File.WriteAllText(ManifestPath, newJson); + WriteManifestWithChecksum(newJson); } } return validInstalls; } catch (JsonException ex) { + // Check whether dotnetup last wrote the file. If the checksum matches, + // we produced invalid JSON ourselves (product bug). If it doesn't match, + // an external edit broke the file (user error). + var errorCode = VerifyChecksumMatches(json) + ? DotnetInstallErrorCode.LocalManifestCorrupted + : DotnetInstallErrorCode.LocalManifestUserCorrupted; + + var suffix = errorCode == DotnetInstallErrorCode.LocalManifestUserCorrupted + ? " The file appears to have been modified outside of dotnetup." + : string.Empty; + throw new DotnetInstallException( - DotnetInstallErrorCode.LocalManifestCorrupted, - $"The dotnetup manifest at {ManifestPath} is corrupt. Consider deleting it and re-running the install.", + errorCode, + $"The dotnetup manifest at {ManifestPath} is corrupt. Consider deleting it and re-running the install.{suffix}", ex); } } @@ -124,8 +137,7 @@ public void AddInstalledVersion(DotnetInstall version) var installs = GetInstalledVersions().ToList(); installs.Add(version); var json = JsonSerializer.Serialize(installs, DotnetupManifestJsonContext.Default.ListDotnetInstall); - Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); - File.WriteAllText(ManifestPath, json); + WriteManifestWithChecksum(json); } public void RemoveInstalledVersion(DotnetInstall version) @@ -136,6 +148,51 @@ public void RemoveInstalledVersion(DotnetInstall version) var installs = GetInstalledVersions().ToList(); installs.RemoveAll(i => DotnetupUtilities.PathsEqual(i.InstallRoot.Path, version.InstallRoot.Path) && i.Version.Equals(version.Version)); var json = JsonSerializer.Serialize(installs, DotnetupManifestJsonContext.Default.ListDotnetInstall); + WriteManifestWithChecksum(json); + } + + /// + /// Writes manifest JSON and a companion SHA-256 checksum file atomically. + /// The checksum lets us distinguish product corruption (our bug) from + /// user edits on the next read. + /// + private void WriteManifestWithChecksum(string json) + { + Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!); File.WriteAllText(ManifestPath, json); + File.WriteAllText(ChecksumPath, ComputeHash(json)); + } + + /// + /// Returns true if the raw JSON content matches the last checksum dotnetup wrote. + /// If the checksum file is missing or unreadable, returns false (assume external edit). + /// + private bool VerifyChecksumMatches(string rawJson) + { + try + { + if (!File.Exists(ChecksumPath)) + { + return false; + } + + var storedHash = File.ReadAllText(ChecksumPath).Trim(); + var currentHash = ComputeHash(rawJson); + return string.Equals(storedHash, currentHash, StringComparison.OrdinalIgnoreCase); + } + catch + { + // If we can't read the checksum file, assume external modification. + return false; + } + } + + private string ChecksumPath => ManifestPath + ".sha256"; + + private static string ComputeHash(string content) + { + var bytes = Encoding.UTF8.GetBytes(content); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash); } } diff --git a/src/Installer/dotnetup/Program.cs b/src/Installer/dotnetup/Program.cs index e5b30858d75b..b552eb735cca 100644 --- a/src/Installer/dotnetup/Program.cs +++ b/src/Installer/dotnetup/Program.cs @@ -34,6 +34,14 @@ public static int Main(string[] args) var result = Parser.Invoke(args); rootActivity?.SetTag(TelemetryTagNames.ExitCode, result); rootActivity?.SetStatus(result == 0 ? ActivityStatusCode.Ok : ActivityStatusCode.Error); + + // Propagate error tags from the command activity to the root span + // so workbook queries on either span see error.type, error.category, etc. + if (result != 0) + { + DotnetupTelemetry.Instance.ApplyLastErrorToActivity(rootActivity); + } + return result; } catch (Exception ex) @@ -51,6 +59,11 @@ public static int Main(string[] args) } finally { + // Stop the root activity before flushing so the console/Azure Monitor + // exporters see it. The 'using' dispose that follows is a no-op on + // an already-stopped Activity. + rootActivity?.Stop(); + // Ensure telemetry is flushed before exit DotnetupTelemetry.Instance.Flush(); DotnetupTelemetry.Instance.Dispose(); diff --git a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs index 0cdce181541f..d673828d2c94 100644 --- a/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs +++ b/src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs @@ -50,6 +50,12 @@ public sealed class DotnetupTelemetry : IDisposable /// public bool Enabled { get; } + /// + /// Gets the last recorded error info. Used to propagate error tags + /// from a command activity to the root activity. + /// + public ExceptionErrorInfo? LastErrorInfo { get; private set; } + /// /// Gets the current session ID. /// @@ -89,13 +95,12 @@ private DotnetupTelemetry() o.ConnectionString = ConnectionString; }); -#if DEBUG - // Console exporter for local debugging + // Console exporter for local debugging / E2E test verification. + // Set DOTNETUP_TELEMETRY_DEBUG=1 to enable. if (Environment.GetEnvironmentVariable("DOTNETUP_TELEMETRY_DEBUG") == "1") { builder.AddConsoleExporter(); } -#endif _tracerProvider = builder.Build(); } @@ -148,6 +153,33 @@ public void RecordException(Activity? activity, Exception ex, string? errorCode var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); ErrorCodeMapper.ApplyErrorTags(activity, errorInfo, errorCode); + LastErrorInfo = errorInfo; + } + + /// + /// Applies the last recorded error info to the given activity. + /// Call this on the root activity after a command fails, so the root span + /// also carries error tags for workbook queries that look at all spans. + /// + /// The activity to apply error tags to (typically the root activity). + public void ApplyLastErrorToActivity(Activity? activity) + { + if (activity == null || LastErrorInfo == null) + { + return; + } + + ErrorCodeMapper.ApplyErrorTags(activity, LastErrorInfo); + } + + /// + /// Sets the last error info directly. Use this for non-exception error paths + /// (e.g., ) that need to propagate + /// error tags to the root span. + /// + public void SetLastErrorInfo(ExceptionErrorInfo errorInfo) + { + LastErrorInfo = errorInfo; } /// diff --git a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs index b8ce673545e9..66d6fd1f176b 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs @@ -43,6 +43,7 @@ internal static ErrorCategory ClassifyInstallError(DotnetInstallErrorCode errorC DotnetInstallErrorCode.InstallationLocked => ErrorCategory.Product, DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, + DotnetInstallErrorCode.LocalManifestUserCorrupted => ErrorCategory.User, DotnetInstallErrorCode.Unknown => ErrorCategory.Product, _ => ErrorCategory.Product diff --git a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json index bf43b352cf33..1d35d8e2e553 100644 --- a/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json +++ b/src/Installer/dotnetup/Telemetry/dotnetup-workbook.json @@ -520,7 +520,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where error_category == \"product\" or isempty(error_category)\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| extend error_type = coalesce(tostring(customDimensions[\"error.type\"]), \"(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", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"dotnetup\"\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, @@ -534,7 +534,7 @@ "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", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"dotnetup\"\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| 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, @@ -548,9 +548,9 @@ "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", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"dotnetup\"\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 error_details = tostring(customDimensions[\"error.details\"]),\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| summarize Count = count(), latest_timestamp = max(timestamp) by error_type, error_details, hresult, os, version\n| order by Count desc\n| take 25\n| project ['Error Type'] = error_type, ['Error Details'] = error_details, Count, ['Last Seen'] = latest_timestamp, hresult, os, version", "size": 1, - "title": "Recent Product Failures (Detailed)", + "title": "Product Failures by Error (Counts)", "queryType": 0, "resourceType": "microsoft.insights/components", "visualization": "table" @@ -561,7 +561,7 @@ "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", + "query": "let devFilter = '{DevBuildFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"dotnetup\"\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, @@ -575,7 +575,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| 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", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"dotnetup\"\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.", @@ -597,7 +597,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where success == false\n| extend is_dev = tobool(customDimensions[\"dev.build\"])\n| extend version = tostring(customDimensions[\"dotnetup.version\"])\n| extend error_category = tostring(customDimensions[\"error.category\"])\n| where error_category == \"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", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"dotnetup\"\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.", @@ -612,9 +612,9 @@ "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", + "query": "let devFilter = '{DevBuildFilter}';\nlet versionFilter = '{VersionFilter}';\ndependencies\n| where timestamp {TimeRange}\n| where name == \"dotnetup\"\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 error_details = tostring(customDimensions[\"error.details\"]),\n os = tostring(customDimensions[\"os.platform\"]),\n version = tostring(customDimensions[\"dotnetup.version\"])\n| where error_category == \"user\"\n| where (devFilter == 'all') or (devFilter == 'exclude' and is_dev == false) or (devFilter == 'only' and is_dev == true)\n| where (versionFilter == 'all') or (version == versionFilter)\n| summarize Count = count(), latest_timestamp = max(timestamp) by error_type, error_details, os, version\n| order by Count desc\n| take 25\n| project ['Error Type'] = error_type, ['Error Details'] = error_details, Count, ['Last Seen'] = latest_timestamp, os, version", "size": 1, - "title": "Recent User Errors", + "title": "User Errors by Error (Counts)", "noDataMessage": "No user errors recorded yet.", "queryType": 0, "resourceType": "microsoft.insights/components", diff --git a/test/dotnetup.Tests/ErrorCodeMapperTests.cs b/test/dotnetup.Tests/ErrorCodeMapperTests.cs index 6b49e1fa941e..b386baad893c 100644 --- a/test/dotnetup.Tests/ErrorCodeMapperTests.cs +++ b/test/dotnetup.Tests/ErrorCodeMapperTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Net; using System.Net.Sockets; using Microsoft.Dotnet.Installation; @@ -224,3 +225,168 @@ private class CustomTestException : Exception public CustomTestException(string message) : base(message) { } } } + +/// +/// Tests that error info is correctly propagated from command-level spans +/// to root-level spans, ensuring the telemetry workbook can see error.type +/// regardless of which span it queries. +/// +public class ErrorPropagationTests : IDisposable +{ + private readonly ActivitySource _testSource = new("Test.ErrorPropagation"); + private readonly ActivityListener _listener; + private readonly List _capturedActivities = new(); + + public ErrorPropagationTests() + { + _listener = new ActivityListener + { + ShouldListenTo = source => source.Name == "Test.ErrorPropagation", + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => _capturedActivities.Add(activity) + }; + ActivitySource.AddActivityListener(_listener); + } + + public void Dispose() + { + _listener.Dispose(); + _testSource.Dispose(); + } + + [Fact] + public void ErrorInfo_CanBePropagated_FromCommandSpanToRootSpan() + { + // Simulate the two-span architecture: root "dotnetup" + child "command/sdk/install" + using var rootActivity = _testSource.StartActivity("dotnetup", ActivityKind.Internal)!; + using var commandActivity = _testSource.StartActivity("command/sdk/install", ActivityKind.Internal)!; + + // Exception occurs during command execution + var ex = new IOException("Disk full", unchecked((int)0x80070070)); + var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); + + // Step 1: Error tags applied to command span (as CommandBase does) + ErrorCodeMapper.ApplyErrorTags(commandActivity, errorInfo); + + // Step 2: Same error info propagated to root span (as Program.Main now does) + ErrorCodeMapper.ApplyErrorTags(rootActivity, errorInfo); + + // Both spans should now carry identical error tags + Assert.Equal("DiskFull", commandActivity.GetTagItem("error.type")); + Assert.Equal("DiskFull", rootActivity.GetTagItem("error.type")); + + Assert.Equal("user", commandActivity.GetTagItem("error.category")); + Assert.Equal("user", rootActivity.GetTagItem("error.category")); + + Assert.Equal(unchecked((int)0x80070070), commandActivity.GetTagItem("error.hresult")); + Assert.Equal(unchecked((int)0x80070070), rootActivity.GetTagItem("error.hresult")); + + Assert.Equal("ERROR_DISK_FULL", commandActivity.GetTagItem("error.details")); + Assert.Equal("ERROR_DISK_FULL", rootActivity.GetTagItem("error.details")); + } + + [Fact] + public void ErrorInfo_PropagatedToRootSpan_IncludesStackTrace() + { + using var rootActivity = _testSource.StartActivity("dotnetup")!; + + // Create an exception with a real stack trace + Exception thrownEx; + try + { + throw new InvalidOperationException("assertion failed in mutex"); + } + catch (Exception ex) + { + thrownEx = ex; + } + + var errorInfo = ErrorCodeMapper.GetErrorInfo(thrownEx); + ErrorCodeMapper.ApplyErrorTags(rootActivity, errorInfo); + + Assert.Equal("InvalidOperation", rootActivity.GetTagItem("error.type")); + Assert.Equal("product", rootActivity.GetTagItem("error.category")); + + var stackTrace = rootActivity.GetTagItem("error.stack_trace") as string; + Assert.NotNull(stackTrace); + Assert.Contains("ErrorInfo_PropagatedToRootSpan_IncludesStackTrace", stackTrace); + } + + [Fact] + public void ErrorInfo_HttpError_PropagatedToRootSpan_IncludesStatusCode() + { + using var rootActivity = _testSource.StartActivity("dotnetup")!; + using var commandActivity = _testSource.StartActivity("command/sdk/install")!; + + var ex = new HttpRequestException("Not found", null, HttpStatusCode.NotFound); + var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); + + ErrorCodeMapper.ApplyErrorTags(commandActivity, errorInfo); + ErrorCodeMapper.ApplyErrorTags(rootActivity, errorInfo); + + // Both spans carry the HTTP status + Assert.Equal(404, commandActivity.GetTagItem("error.http_status")); + Assert.Equal(404, rootActivity.GetTagItem("error.http_status")); + + Assert.Equal("Http404", commandActivity.GetTagItem("error.type")); + Assert.Equal("Http404", rootActivity.GetTagItem("error.type")); + } + + [Fact] + public void ErrorInfo_UserError_PropagatedToRootSpan_PreservesCategory() + { + using var rootActivity = _testSource.StartActivity("dotnetup")!; + + var errorInfo = new ExceptionErrorInfo("InvalidVersion", ErrorCategory.User, Details: "user typed garbage"); + + ErrorCodeMapper.ApplyErrorTags(rootActivity, errorInfo); + + Assert.Equal("user", rootActivity.GetTagItem("error.category")); + Assert.Equal("InvalidVersion", rootActivity.GetTagItem("error.type")); + } + + [Fact] + public void RootSpan_WithoutPropagation_HasNoErrorTags() + { + // Verifies the problem scenario: if error is only on command span, + // root span has no error tags. + using var rootActivity = _testSource.StartActivity("dotnetup")!; + using var commandActivity = _testSource.StartActivity("command/sdk/install")!; + + var ex = new IOException("Disk full", unchecked((int)0x80070070)); + var errorInfo = ErrorCodeMapper.GetErrorInfo(ex); + + // Only apply to command span + ErrorCodeMapper.ApplyErrorTags(commandActivity, errorInfo); + + // Root span should NOT have error tags + Assert.Null(rootActivity.GetTagItem("error.type")); + Assert.Null(rootActivity.GetTagItem("error.category")); + + // Command span should have them + Assert.Equal("DiskFull", commandActivity.GetTagItem("error.type")); + } + + [Fact] + public void GetErrorInfo_IsReusable_AcrossMultipleActivities() + { + // Verifies that the same ExceptionErrorInfo can be applied to multiple spans + using var activity1 = _testSource.StartActivity("span1")!; + using var activity2 = _testSource.StartActivity("span2")!; + using var activity3 = _testSource.StartActivity("span3")!; + + var errorInfo = new ExceptionErrorInfo("TestError", ErrorCategory.Product, StatusCode: 500, Details: "server error"); + + ErrorCodeMapper.ApplyErrorTags(activity1, errorInfo); + ErrorCodeMapper.ApplyErrorTags(activity2, errorInfo); + ErrorCodeMapper.ApplyErrorTags(activity3, errorInfo); + + // All three should have identical tags + foreach (var activity in new[] { activity1, activity2, activity3 }) + { + Assert.Equal("TestError", activity.GetTagItem("error.type")); + Assert.Equal("product", activity.GetTagItem("error.category")); + Assert.Equal(500, activity.GetTagItem("error.http_status")); + } + } +} diff --git a/test/dotnetup.Tests/RuntimeInstallTests.cs b/test/dotnetup.Tests/RuntimeInstallTests.cs index 49289f8c4fa8..aac548ae6f86 100644 --- a/test/dotnetup.Tests/RuntimeInstallTests.cs +++ b/test/dotnetup.Tests/RuntimeInstallTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.IO; using System.Linq; using FluentAssertions; using Microsoft.Deployment.DotNet.Releases; @@ -120,6 +121,100 @@ public void ComponentSpecParsing_MissingVersion_ReturnsError(string spec) #endregion + #region Manifest Checksum Tests + + [Fact] + public void ManifestChecksum_WrittenOnCreate() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + _ = new DotnetupSharedManifest(testEnv.ManifestPath); + + var checksumPath = testEnv.ManifestPath + ".sha256"; + File.Exists(checksumPath).Should().BeTrue("checksum sidecar should be created with manifest"); + File.ReadAllText(checksumPath).Trim().Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void ManifestChecksum_UpdatedOnWrite() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + var manifest = new DotnetupSharedManifest(testEnv.ManifestPath); + var installRoot = new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()); + + var checksumPath = testEnv.ManifestPath + ".sha256"; + var checksumBefore = File.ReadAllText(checksumPath).Trim(); + + using (var mutex = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates)) + { + manifest.AddInstalledVersion(new DotnetInstall(installRoot, new ReleaseVersion("9.0.100"), InstallComponent.SDK)); + } + + var checksumAfter = File.ReadAllText(checksumPath).Trim(); + checksumAfter.Should().NotBe(checksumBefore, "checksum should change after add"); + } + + [Fact] + public void ManifestCorrupted_WithValidChecksum_ThrowsLocalManifestCorrupted() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + var manifest = new DotnetupSharedManifest(testEnv.ManifestPath); + + // Write valid manifest, then corrupt the content without updating the checksum. + // This simulates a scenario where dotnetup wrote the file but it got corrupted + // (e.g., disk error, partial write). + var checksumPath = testEnv.ManifestPath + ".sha256"; + var originalContent = File.ReadAllText(testEnv.ManifestPath); + var originalChecksum = File.ReadAllText(checksumPath); + + // Write corrupt JSON that still matches the stored checksum (impossible naturally, + // so instead: write corrupt JSON, then rewrite checksum of the corrupt content) + var corruptContent = "NOT VALID JSON {{{"; + File.WriteAllText(testEnv.ManifestPath, corruptContent); + + // Compute and write checksum of corrupt content to simulate dotnetup having written it + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(corruptContent)); + File.WriteAllText(checksumPath, Convert.ToHexString(hash)); + + using var mutex = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); + var ex = Assert.Throws(() => manifest.GetInstalledVersions().ToList()); + ex.ErrorCode.Should().Be(DotnetInstallErrorCode.LocalManifestCorrupted, + "checksum matches corrupt content → product error"); + } + + [Fact] + public void ManifestCorrupted_WithMismatchedChecksum_ThrowsLocalManifestUserCorrupted() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + var manifest = new DotnetupSharedManifest(testEnv.ManifestPath); + + // Manifest was written by dotnetup (checksum exists), then user edits the file + File.WriteAllText(testEnv.ManifestPath, "user broke this {[}"); + + using var mutex = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); + var ex = Assert.Throws(() => manifest.GetInstalledVersions().ToList()); + ex.ErrorCode.Should().Be(DotnetInstallErrorCode.LocalManifestUserCorrupted, + "checksum doesn't match user-edited content → user error"); + } + + [Fact] + public void ManifestCorrupted_WithNoChecksum_ThrowsLocalManifestUserCorrupted() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + var manifest = new DotnetupSharedManifest(testEnv.ManifestPath); + + // Delete checksum file and corrupt manifest — simulates user-created manifest + var checksumPath = testEnv.ManifestPath + ".sha256"; + File.Delete(checksumPath); + File.WriteAllText(testEnv.ManifestPath, "garbage data"); + + using var mutex = new ScopedMutex(Constants.MutexNames.ModifyInstallationStates); + var ex = Assert.Throws(() => manifest.GetInstalledVersions().ToList()); + ex.ErrorCode.Should().Be(DotnetInstallErrorCode.LocalManifestUserCorrupted, + "no checksum file → assume external edit → user error"); + } + + #endregion + #region Manifest Tracking Tests [Fact] diff --git a/test/dotnetup.Tests/TelemetryE2ETests.cs b/test/dotnetup.Tests/TelemetryE2ETests.cs new file mode 100644 index 000000000000..453938654181 --- /dev/null +++ b/test/dotnetup.Tests/TelemetryE2ETests.cs @@ -0,0 +1,372 @@ +// 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.RegularExpressions; +using FluentAssertions; +using Microsoft.DotNet.Tools.Dotnetup.Tests.Utilities; +using Xunit; + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +/// +/// End-to-end tests that verify telemetry output by running dotnetup as a separate process +/// with the console exporter enabled (DOTNETUP_TELEMETRY_DEBUG=1). +/// +public class TelemetryE2ETests +{ + /// + /// Environment variables that enable telemetry debug output. + /// + private static readonly Dictionary s_telemetryEnvVars = new() + { + ["DOTNETUP_TELEMETRY_DEBUG"] = "1", + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "0", + }; + + [Fact] + public void InvalidVersion_ProducesUserError_OnRootSpan() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + var args = DotnetupTestUtilities.BuildSdkArguments("999.999.999", testEnv.InstallPath, testEnv.ManifestPath); + + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + args, captureOutput: true, workingDirectory: testEnv.TempRoot, environmentVariables: s_telemetryEnvVars); + + exitCode.Should().NotBe(0, "requesting a nonexistent SDK version should fail"); + + var spans = ParseTelemetrySpans(output); + spans.Should().NotBeEmpty("console exporter should emit telemetry spans"); + + var rootSpan = spans.FirstOrDefault(s => s.DisplayName == "dotnetup"); + rootSpan.Should().NotBeNull("root 'dotnetup' span should be emitted"); + + rootSpan!.Tags.Should().ContainKey("error.type", "root span should have error.type tag"); + rootSpan.Tags.Should().ContainKey("error.category", "root span should have error.category tag"); + } + + [Fact] + public void InvalidVersion_ErrorTags_AreOnCommandSpan() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + var args = DotnetupTestUtilities.BuildSdkArguments("999.999.999", testEnv.InstallPath, testEnv.ManifestPath); + + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + args, captureOutput: true, workingDirectory: testEnv.TempRoot, environmentVariables: s_telemetryEnvVars); + + exitCode.Should().NotBe(0); + + var spans = ParseTelemetrySpans(output); + var commandSpan = spans.FirstOrDefault(s => s.DisplayName.StartsWith("command/", StringComparison.Ordinal)); + commandSpan.Should().NotBeNull("a command/* span should be emitted"); + + commandSpan!.Tags.Should().ContainKey("error.type", "command span should have error.type tag"); + } + + [Fact] + public void InvalidVersion_ErrorDetails_ArePropagatedToRootSpan() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + var args = DotnetupTestUtilities.BuildSdkArguments("999.999.999", testEnv.InstallPath, testEnv.ManifestPath); + + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + args, captureOutput: true, workingDirectory: testEnv.TempRoot, environmentVariables: s_telemetryEnvVars); + + exitCode.Should().NotBe(0); + + var spans = ParseTelemetrySpans(output); + var rootSpan = spans.FirstOrDefault(s => s.DisplayName == "dotnetup"); + rootSpan.Should().NotBeNull(); + + // The root span should have error details propagated from the command span + if (rootSpan!.Tags.TryGetValue("error.details", out string? details)) + { + details.Should().NotBeNullOrWhiteSpace("error.details should contain a meaningful message"); + } + + // The error.type should be present and match between command and root spans + var commandSpan = spans.FirstOrDefault(s => s.DisplayName.StartsWith("command/", StringComparison.Ordinal)); + if (commandSpan != null && + commandSpan.Tags.TryGetValue("error.type", out string? commandErrorType) && + rootSpan.Tags.TryGetValue("error.type", out string? rootErrorType)) + { + rootErrorType.Should().Be(commandErrorType, "error.type should match between command and root spans"); + } + } + + [Fact] + public void SuccessfulHelp_ProducesNoErrorTags() + { + // Running --help should succeed without error tags + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + ["--help"], captureOutput: true, environmentVariables: s_telemetryEnvVars); + + exitCode.Should().Be(0, "dotnetup --help should succeed"); + + var spans = ParseTelemetrySpans(output); + + // If telemetry emits spans (it may not for --help), they should have no error tags + var rootSpan = spans.FirstOrDefault(s => s.DisplayName == "dotnetup"); + if (rootSpan != null) + { + rootSpan.Tags.Should().NotContainKey("error.type", "--help should not produce error tags"); + rootSpan.Tags.Should().NotContainKey("error.category", "--help should not produce error tags"); + } + } + + [Fact] + public void InvalidVersion_RootSpan_HasCorrectDisplayName() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + var args = DotnetupTestUtilities.BuildSdkArguments("999.999.999", testEnv.InstallPath, testEnv.ManifestPath); + + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + args, captureOutput: true, workingDirectory: testEnv.TempRoot, environmentVariables: s_telemetryEnvVars); + + var spans = ParseTelemetrySpans(output); + var rootSpan = spans.FirstOrDefault(s => s.DisplayName == "dotnetup"); + rootSpan.Should().NotBeNull("root span should have DisplayName 'dotnetup'"); + + var commandSpan = spans.FirstOrDefault(s => s.DisplayName.StartsWith("command/", StringComparison.Ordinal)); + commandSpan.Should().NotBeNull("command span should start with 'command/'"); + commandSpan!.DisplayName.Should().Contain("sdk", "SDK command span should contain 'sdk'"); + } + + [Fact] + public void InstallPathIsFile_ProducesUserError() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + + // Create a file where the install path would be — user error: path is a file, not a directory + string filePath = Path.Combine(testEnv.TempRoot, "not-a-directory"); + File.WriteAllText(filePath, "this is a file"); + + var args = DotnetupTestUtilities.BuildSdkArguments("9.0", filePath, testEnv.ManifestPath); + + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + args, captureOutput: true, workingDirectory: testEnv.TempRoot, environmentVariables: s_telemetryEnvVars); + + exitCode.Should().NotBe(0, "install-path pointing to a file should fail"); + output.Should().Contain("existing file", "error message should mention it's a file"); + + var spans = ParseTelemetrySpans(output); + var rootSpan = spans.FirstOrDefault(s => s.DisplayName == "dotnetup"); + rootSpan.Should().NotBeNull("root span should be emitted"); + + rootSpan!.Tags.Should().ContainKey("error.type"); + rootSpan.Tags["error.type"].Should().Be("install_path_is_file"); + rootSpan.Tags.Should().ContainKey("error.category"); + rootSpan.Tags["error.category"].Should().Be("user"); + } + + [Fact] + public void CorruptManifest_UserEdited_ProducesUserError() + { + // Write a corrupt manifest and set the env var so dotnetup reads it. + // No checksum file → user error (external edit). + string tempDir = Path.Combine(Path.GetTempPath(), $"dnup-e2e-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + string manifestPath = Path.Combine(tempDir, "manifest.json"); + File.WriteAllText(manifestPath, "NOT VALID {{{ JSON"); + + try + { + var envVars = new Dictionary(s_telemetryEnvVars) + { + ["DOTNET_TESTHOOK_MANIFEST_PATH"] = manifestPath, + }; + + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + ["list"], captureOutput: true, environmentVariables: envVars); + + exitCode.Should().NotBe(0, "corrupt manifest should cause list to fail"); + + var spans = ParseTelemetrySpans(output); + var rootSpan = spans.FirstOrDefault(s => s.DisplayName == "dotnetup"); + rootSpan.Should().NotBeNull("root span should be emitted"); + + rootSpan!.Tags.Should().ContainKey("error.type"); + rootSpan.Tags["error.type"].Should().Be("LocalManifestUserCorrupted"); + rootSpan.Tags.Should().ContainKey("error.category"); + rootSpan.Tags["error.category"].Should().Be("user"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void CorruptManifest_DotnetupWrote_ProducesProductError() + { + // Write a corrupt manifest WITH a matching checksum → product error. + // This simulates dotnetup having written corrupt JSON (our bug). + string tempDir = Path.Combine(Path.GetTempPath(), $"dnup-e2e-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + string manifestPath = Path.Combine(tempDir, "manifest.json"); + string checksumPath = manifestPath + ".sha256"; + string corruptContent = "{ broken json {{["; + File.WriteAllText(manifestPath, corruptContent); + + // Write checksum that matches the corrupt content + var hash = System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(corruptContent)); + File.WriteAllText(checksumPath, Convert.ToHexString(hash)); + + try + { + var envVars = new Dictionary(s_telemetryEnvVars) + { + ["DOTNET_TESTHOOK_MANIFEST_PATH"] = manifestPath, + }; + + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + ["list"], captureOutput: true, environmentVariables: envVars); + + exitCode.Should().NotBe(0, "corrupt manifest should cause list to fail"); + + var spans = ParseTelemetrySpans(output); + var rootSpan = spans.FirstOrDefault(s => s.DisplayName == "dotnetup"); + rootSpan.Should().NotBeNull("root span should be emitted"); + + rootSpan!.Tags.Should().ContainKey("error.type"); + rootSpan.Tags["error.type"].Should().Be("LocalManifestCorrupted"); + rootSpan.Tags.Should().ContainKey("error.category"); + rootSpan.Tags["error.category"].Should().Be("product"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void TelemetryDisabled_ProducesNoSpans() + { + var envVars = new Dictionary + { + ["DOTNETUP_TELEMETRY_DEBUG"] = "1", + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + }; + + (int exitCode, string output) = DotnetupTestUtilities.RunDotnetupProcess( + ["--help"], captureOutput: true, environmentVariables: envVars); + + exitCode.Should().Be(0); + + var spans = ParseTelemetrySpans(output); + spans.Should().BeEmpty("no telemetry spans should be emitted when telemetry is opted out"); + } + + /// + /// Parses the OpenTelemetry ConsoleExporter output into structured span records. + /// The console exporter outputs blocks like: + /// + /// Activity.TraceId: abc123 + /// Activity.SpanId: def456 + /// Activity.DisplayName: dotnetup + /// ... + /// Activity.Tags: + /// error.type: DotnetInstallException + /// error.category: user + /// + /// + private static List ParseTelemetrySpans(string output) + { + var spans = new List(); + var lines = output.Split('\n', StringSplitOptions.None); + + TelemetrySpan? current = null; + bool inTags = false; + + foreach (string rawLine in lines) + { + string line = rawLine.TrimEnd('\r'); + + // Start of a new span block + if (line.StartsWith("Activity.TraceId:", StringComparison.Ordinal)) + { + if (current != null) + { + spans.Add(current); + } + + current = new TelemetrySpan(); + inTags = false; + continue; + } + + if (current == null) + { + continue; + } + + // Parse DisplayName + var displayNameMatch = Regex.Match(line, @"^Activity\.DisplayName:\s*(.+)$"); + if (displayNameMatch.Success) + { + current.DisplayName = displayNameMatch.Groups[1].Value.Trim(); + inTags = false; + continue; + } + + // Parse StatusCode + var statusMatch = Regex.Match(line, @"^StatusCode\s*:\s*(.+)$"); + if (statusMatch.Success) + { + current.StatusCode = statusMatch.Groups[1].Value.Trim(); + inTags = false; + continue; + } + + // Start of tags section + if (line.TrimStart().StartsWith("Activity.Tags:", StringComparison.Ordinal)) + { + inTags = true; + continue; + } + + // Parse tag key-value pairs (indented lines after Activity.Tags:) + if (inTags) + { + var tagMatch = Regex.Match(line, @"^\s+([a-z0-9._-]+)\s*:\s*(.*)$", RegexOptions.IgnoreCase); + if (tagMatch.Success) + { + current.Tags[tagMatch.Groups[1].Value.Trim()] = tagMatch.Groups[2].Value.Trim(); + } + else if (!string.IsNullOrWhiteSpace(line) && !line.StartsWith(" ", StringComparison.Ordinal)) + { + // No longer in tags section + inTags = false; + } + } + + // Detect other Activity. sections that end the tags block + if (line.StartsWith("Activity.", StringComparison.Ordinal) && !line.StartsWith("Activity.Tags:", StringComparison.Ordinal)) + { + inTags = false; + } + } + + // Don't forget the last span + if (current != null) + { + spans.Add(current); + } + + return spans; + } + + /// + /// Represents a parsed telemetry span from console exporter output. + /// + private sealed class TelemetrySpan + { + public string DisplayName { get; set; } = string.Empty; + public string StatusCode { get; set; } = string.Empty; + public Dictionary Tags { get; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/test/dotnetup.Tests/TelemetryTests.cs b/test/dotnetup.Tests/TelemetryTests.cs index 6a5e324c2d64..02cb45855f9e 100644 --- a/test/dotnetup.Tests/TelemetryTests.cs +++ b/test/dotnetup.Tests/TelemetryTests.cs @@ -318,6 +318,27 @@ public void RecordException_WithNullActivity_DoesNotThrow() Assert.Null(exception); } + + [Fact] + public void ApplyLastErrorToActivity_WithNullActivity_DoesNotThrow() + { + var exception = Record.Exception(() => + DotnetupTelemetry.Instance.ApplyLastErrorToActivity(null)); + + Assert.Null(exception); + } + + [Fact] + public void LastErrorInfo_IsNullInitially_WhenNoExceptionRecorded() + { + // LastErrorInfo starts from whatever state previous tests left it in + // but null-activity RecordException calls don't update it, so this + // verifies ApplyLastErrorToActivity is safe to call regardless. + var exception = Record.Exception(() => + DotnetupTelemetry.Instance.ApplyLastErrorToActivity(null)); + + Assert.Null(exception); + } } public class FirstRunNoticeTests : IDisposable diff --git a/test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs b/test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs index 3e25d3f7a4fd..cd0b4ff23286 100644 --- a/test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs +++ b/test/dotnetup.Tests/Utilities/DotnetupTestUtilities.cs @@ -140,8 +140,14 @@ public static string GetDotnetupExecutablePath() /// /// Command line arguments for dotnetup /// Whether to capture and return the output + /// Working directory for the process + /// Additional environment variables to set on the process /// A tuple with exit code and captured output (if requested) - public static (int exitCode, string output) RunDotnetupProcess(string[] args, bool captureOutput = false, string? workingDirectory = null) + public static (int exitCode, string output) RunDotnetupProcess( + string[] args, + bool captureOutput = false, + string? workingDirectory = null, + Dictionary? environmentVariables = null) { string dotnetupPath = GetDotnetupExecutablePath(); @@ -157,6 +163,15 @@ public static (int exitCode, string output) RunDotnetupProcess(string[] args, bo // Suppress the .NET welcome message / first-run experience in test output process.StartInfo.Environment["DOTNET_NOLOGO"] = "1"; + // Apply any additional environment variables + if (environmentVariables != null) + { + foreach (var kvp in environmentVariables) + { + process.StartInfo.Environment[kvp.Key] = kvp.Value; + } + } + StringBuilder outputBuilder = new(); if (captureOutput) {