Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public enum DotnetInstallErrorCode

/// <summary>The dotnetup installation manifest is corrupted.</summary>
LocalManifestCorrupted,

/// <summary>The dotnetup installation manifest was modified externally and is now corrupted.</summary>
LocalManifestUserCorrupted,
}

/// <summary>
Expand Down
18 changes: 18 additions & 0 deletions src/Installer/dotnetup/CommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}

/// <summary>
Expand Down
16 changes: 16 additions & 0 deletions src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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);
}

Expand Down
71 changes: 64 additions & 7 deletions src/Installer/dotnetup/DotnetupSharedManifest.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -20,8 +22,8 @@ private void EnsureManifestExists()
{
if (!File.Exists(ManifestPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!);
File.WriteAllText(ManifestPath, JsonSerializer.Serialize((List<DotnetInstall>)[], DotnetupManifestJsonContext.Default.ListDotnetInstall));
var json = JsonSerializer.Serialize((List<DotnetInstall>)[], DotnetupManifestJsonContext.Default.ListDotnetInstall);
WriteManifestWithChecksum(json);
}
}

Expand Down Expand Up @@ -86,16 +88,27 @@ public IEnumerable<DotnetInstall> 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);
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}

/// <summary>
/// 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.
/// </summary>
private void WriteManifestWithChecksum(string json)
{
Directory.CreateDirectory(Path.GetDirectoryName(ManifestPath)!);
File.WriteAllText(ManifestPath, json);
File.WriteAllText(ChecksumPath, ComputeHash(json));
}

/// <summary>
/// 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).
/// </summary>
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);
}
}
13 changes: 13 additions & 0 deletions src/Installer/dotnetup/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
Expand Down
38 changes: 35 additions & 3 deletions src/Installer/dotnetup/Telemetry/DotnetupTelemetry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public sealed class DotnetupTelemetry : IDisposable
/// </summary>
public bool Enabled { get; }

/// <summary>
/// Gets the last recorded error info. Used to propagate error tags
/// from a command activity to the root activity.
/// </summary>
public ExceptionErrorInfo? LastErrorInfo { get; private set; }

/// <summary>
/// Gets the current session ID.
/// </summary>
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="activity">The activity to apply error tags to (typically the root activity).</param>
public void ApplyLastErrorToActivity(Activity? activity)
{
if (activity == null || LastErrorInfo == null)
{
return;
}

ErrorCodeMapper.ApplyErrorTags(activity, LastErrorInfo);
}

/// <summary>
/// Sets the last error info directly. Use this for non-exception error paths
/// (e.g., <see cref="CommandBase.RecordFailure"/>) that need to propagate
/// error tags to the root span.
/// </summary>
public void SetLastErrorInfo(ExceptionErrorInfo errorInfo)
{
LastErrorInfo = errorInfo;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading