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)
{